Compare commits

..
320 Commits
Author SHA1 Message Date
enricobuehler ca2ff7093a Merge pull request '0.31.2 — the address the host used, from three directions' (#369) from worktree-release-0312-prep into main 2026-08-21 20:50:22 +00:00
enricobuehler a2dc011200 release: 0.31.2 — version bump, notes, CHANGELOG, Play notes
10 commits since v0.31.1 (6 non-merge). Cut from origin/main 48eeae75 (#368
merged).

THE NUMBER: a patch, and unlike the last cut the version table does not even
have to argue for it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h showing NO diff at all against the v0.31.1 tag (not
even a #define, unlike the last two releases), driver protocol 6 / min 3 with
pf-driver-proto unchanged, gamepad channel 3, plugin index schema 1, host event
schema 1, gamescope +pfhdr8 with no new patch files, SDK 0.1.5 and plugin-kit
0.4.4 both untouched. No `!` commit, no feat, no route added or removed, no
breaking change of any kind. Every non-merge commit is fix/refactor/test.

The cycle has a shape: three of the six non-merge commits are the same class of
fault — the host using the wrong local address — reached from three directions.
The data socket bound 0.0.0.0:0 and let routing pick the video source, which the
client's connected socket then dropped in-kernel (#367). Host::detect() froze the
advertised address at process start, so a cold boot that beat the network pinned
127.0.0.1 for the life of the process and broke both mDNS adverts, the Moonlight
session URL, the WoL mac record and HostInfo together (#366). And the firewall
rules guarding the ports those addresses point at admitted any program on the
machine (#368). The fourth is an Android regression from v0.31.1 (#365); the
remaining two are the refactor and test supporting #366.

api/openapi.json changes in DOCUMENTATION ONLY this time — two description
strings on HostInfo, no route, schema, required field or type — plus the stamp.
Re-stamped here, not regenerated: punktfunk-host does not build on macOS, and
#366 regenerated the document itself on a runner where
openapi_document_is_complete_and_checked_in actually executes. "0.31.1" appears
nowhere in either copy afterwards, and the two copies are byte-identical.

That description change is load-bearing rather than cosmetic, so it is called out
as a behaviour change in the CHANGELOG beside the firewall one: HostInfo.local_ip
was a field snapshotted at detect() and is now a method that re-reads per
request, so a consumer that cached it at startup was caching a value that could
be 127.0.0.1 forever.

The other behaviour change is the externally visible one: Windows service install
now scopes all five fixed-port rules to the listening executable while keeping
their localport=, so 5353 is punktfunk's alone and anything else on the machine
that was reachable on mDNS through our any-program rule needs its own. Fallbacks
are asymmetric on purpose — a fixed-port rule that cannot resolve its exe falls
back to the old wide form (a looser rule still streams), while the data-plane
rule skips (it has no port to fall back to, so a program-less version would not
be looser, it would be open).

Also in this commit, because a cut is when docs freshness bites:
docs-site/content/docs/ports.mdx. Its "Video needs nothing opened" bullet has
been wrong for Windows since v0.31.1 added the data-plane rule — it now says so
and names why (no fixed rule can cover a per-session ephemeral port). And the
Windows line gains a Callout for the 5353 change above, since that is the one
thing on this page a reader may have to act on. Callout shape copied from the
proven usage in plugins.mdx (no `title` prop — node_modules is not installed here
and fumadocs' prop surface could not be verified offline).

Play notes are Android-only per whatsnew/TEMPLATE.txt, which this cycle means the
#365 regression alone. The three host-side fixes are deliberately NOT in there:
updating the app does not fix any of them, so listing them on the store page
would promise something the download does not deliver.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with the
Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core --lib 273
passed; the C ABI harness PASSED reporting abi_version=25 (needed `brew install
opus` on this Mac to link — the first run failed on the missing library, not on
the code); cbindgen regenerated include/punktfunk_core.h during that build and it
came out byte-identical to the checked-in file AND to the v0.31.1 tag, which is a
stronger check on the ABI row than diffing it; scripts/ci/check-docs-drift.sh
clean; scripts/ci/check-docs-links.sh clean; the android.yml Play notes gate run
verbatim, 357/500 characters and unique; both openapi copies cmp identical and
stamped 0.31.2; notes voice scan clean (one backticked term in the whole file,
the `punktfunk-host service install` command, and the only technical vocabulary
sits inside `## For developers`).

Not run here, and why: clippy and any punktfunk-host build (does not compile on
macOS — CI covers it), and the Android unit tests (:kit: and :app: were run on
#365 itself; nothing in this commit touches Kotlin).

One judgement call left for the tag: SECURITY.md promises to credit a reporter in
the release notes when the fix is public, and the #368 commit records only "a
user on 2026-08-21" with no name. The notes credit them unnamed. If they want
their name on it, that is a one-line edit to docs/releases/v0.31.2.md before the
tag is pushed.
2026-08-21 20:04:23 +02:00
enricobuehler 48eeae7527 Merge pull request 'The fixed-port firewall rules were open to every program on the machine' (#368) from worktree-firewall-program-scoped-rules into main 2026-08-21 14:14:10 +00:00
enricobuehler 0df4ca957f fix(host,windows): the fixed-port firewall rules were open to every program on the machine
`service install` added `dir=in action=allow` rules carrying only `localport=`,
which admit ANY process on the machine on those ports — GameStream
(47984/47989/47998-48010/48010), the native plane (9777), mgmt (47990), mDNS
(5353), and the console pair (47992/47993). Binding a high port on Windows
needs no elevation, so an unprivileged program could take any of them and be
reachable from the LAN simply by binding first — silently, because our rule is
precisely what suppresses the "Allow this app to communicate on…" prompt that
would otherwise be the only way in.

Scope every rule to the executable that actually listens on it, keeping the
ports: program AND port is strictly tighter than either alone. The host rules
name the host exe (resolved once and shared with the data-plane rule, which
already worked this way and is the pattern the rest now follow); the console
rules name the bundled `<app>/bun/bun.exe` the supervisor spawns.

The old argument for leaving them unscoped — "an install whose recorded exe
path later moves still has its fixed ports open" — does not hold: `service
install` re-runs this whole remove-then-add on every upgrade, so the path is
refreshed rather than left stale.

Fallbacks are deliberate and asymmetric. A fixed-port rule whose program can't
be resolved falls back to the old any-program form, because a looser rule still
streams and no rule is a black screen. The data-plane rule instead skips: it
has no `localport=` to fall back to, so a program-less version of it would not
be a looser rule but an open host.

One externally visible change, called out in installer output: 5353 is ours
alone now, so anything else on the machine that answered mDNS through
punktfunk's rule needs its own.

Reported by a user on 2026-08-21, after the source-IP fix in #367 resolved
their black screen.
2026-08-21 16:05:00 +02:00
enricobuehler 13aa11355e Merge pull request 'Video egressed from whichever interface routing picked, not the one the client dialed' (#367) from worktree-blackscreen-data-plane-source-ip into main 2026-08-21 12:31:44 +00:00
enricobuehler e989d7457f fix(host): video egressed from whichever interface routing picked, not the one the client dialed
`bind_data_socket` bound `0.0.0.0:0`, so the kernel chose the video source
address from the routing table, independently of the address the client's
control connection actually arrived on. The client's data socket is
`connect`ed to the host IP it dialed, so its kernel drops every datagram from
any other source — before userspace, where nothing counts it.

On a host with two live paths to the client (Ethernet and Wi-Fi both up on the
same LAN; a VPN/overlay adapter claiming the route) that is a permanent black
screen with every gauge green: the hole-punch still arrives so the host logs
`punched=true`, `loss_ppm` stays 0 because there are no packets to see gaps
in, and QUIC — which quinn pins to the right local address — carries control,
audio and input perfectly. `from_socket_punch` already documents the mirror of
this assumption for the *client's* source IP; the host side was never checked.

Bind the data socket to `Connection::local_ip()` instead (unmapping an
IPv4-mapped v6 address so the socket can still `connect` to a v4 peer), and
fall back to the wildcard, loudly, when it is unavailable.

Two diagnostics, because this session's log could not answer the question:
- the `data plane bound` line now carries the socket's post-`connect` `local=`
  address — the source the kernel will actually stamp — and WARNs when it
  differs from the address the control plane arrived on.
- the black-screen ERROR no longer asserts "This is a PATH problem, not
  decode" and no longer names `punched=false` as *the* fingerprint. It fired
  with `punched=true` in the field, which contradicts its own advice and sent
  an investigation at the firewall. It now branches on what the bring-up line
  says, and admits its counter is incremented after decrypt and replay checks,
  so a session whose every datagram failed to open reports the same zero.
2026-08-21 14:06:48 +02:00
enricobuehler b05bb1dd48 A cold-booted host advertised 127.0.0.1 and never recovered (#366)
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field for the life of the process. On a cold boot the
host outruns the network — the Windows service is `AutoStart` with no
dependencies — so the route probe failed with ENETUNREACH and the loopback
fallback stuck until someone restarted the host by hand.

Four surfaces broke off that one field: both mDNS adverts published 127.0.0.1 as
their A record, `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010`,
`wol::wake_macs()` dropped the `mac` TXT record and silently disabled
Wake-on-LAN, and `HostInfo.local_ip` reported loopback to the console.

Fixed at the choke point: `primary_local_ip()` never returns loopback and falls
back to the first non-loopback interface address when no default route exists
yet; `Host::local_ip` re-reads instead of freezing; and a live mDNS advert
re-registers when the routed address changes. Also covers a changed DHCP lease
and a host moved between Wi-Fi and Ethernet.
2026-08-21 12:04:15 +00:00
enricobuehler 2898f6b049 test(host): cover the interface fallback the boot race actually takes
The route probe needs a default route, which on a cold boot lands after the NIC
has its address; the fallback is what answers in between, and nothing exercised
it. Split it into `first_lan_ipv4` so a test can assert the one thing that
matters: it never hands back the loopback `get_if_addrs` also reports.
2026-08-21 13:34:44 +02:00
enricobuehler 4eb4e3465b refactor(host): end the mDNS re-announce loop with a channel, not a flag
std's mpsc doubles as the sleep and the stop signal: the loop times out every
IP_RECHECK to re-check the address, and the Advert dropping its sender wakes the
thread immediately instead of leaving it to notice a flag up to 10s later. Drops
the Arc<AtomicBool> and the Drop impl.
2026-08-21 13:32:08 +02:00
enricobuehler 44cd5bfd81 Merge pull request 'The button correction fired on pads that never needed it' (#365) from worktree-android-pad-mapping-regression into main 2026-08-21 11:24:03 +00:00
enricobuehler 8977228a4b fix(host): a cold-booted host advertised 127.0.0.1 and never recovered
`Host::detect()` snapshotted the LAN address once, at process start, and every
consumer read that frozen field forever. On a cold boot the host wins the race
against the network — the Windows service is registered `AutoStart` with no
dependencies — so `primary_local_ip()`'s route probe to 8.8.8.8 failed with
ENETUNREACH and the loopback fallback stuck for the life of the process.
Restarting the host re-ran `detect()` on a live network, which is the workaround
users found.

Four surfaces broke together, all off that one field:

  * both mDNS adverts (`_punktfunk._udp`, `_nvstream._tcp`) published `127.0.0.1`
    as their A record — the address a client lists and dials;
  * `session_url_xml()` handed Moonlight `rtsp://127.0.0.1:48010` after /launch,
    so even a manually-added host could not stream;
  * `wol::wake_macs()` found no interface for loopback and dropped the `mac` TXT
    record, silently disabling Wake-on-LAN;
  * `HostInfo.local_ip` reported loopback to the web console.

Fixed at the choke point rather than per-caller:

  * `primary_local_ip()` never returns loopback or the unspecified address. When
    the route probe fails it falls back to the first non-loopback interface
    address, which exists as soon as the NIC is configured even if the default
    route is not installed yet — the common shape of the boot race.
  * `Host::local_ip` becomes a method that re-reads instead of a field that
    freezes. A `connect(2)` on an unconnected UDP socket sends no packets and
    costs nothing beside the HTTP response it is serialized into.
  * mDNS records are pushed, not polled, so a live advert re-registers when the
    routed address changes (`discovery::advertise_live`, shared by both service
    types). It polls the routed address rather than subscribing to the daemon's
    IpAdd events because the boot race usually resolves without one: the NIC
    often has its address before we register and only the route lands late.

This also covers the sibling cases that were never reported — DHCP handing out a
different lease, and a host moved between Wi-Fi and Ethernet.
2026-08-21 13:21:52 +02:00
enricobuehler 6a82a602a1 fix(clients/android): the button correction fired on pads that never needed it
Two field reports (2026-08-21), one shape: a GameSir G8+ and an Xbox Elite
Series 2 ("Xbox Wireless Controller" over Bluetooth) with X answering Y, Y
answering LB, and the two shoulders answering menu buttons — everything else
correct. That is not a stray mapping, it is exactly what `GENERIC_XBOX` does to
scancodes `0x133`/`0x134`/`0x136`/`0x137`, so the correction added yesterday was
firing on pads whose buttons were already where `Generic.kl` says they are.

It fired because it asked the wrong question. `hasKeys(BUTTON_C, BUTTON_Z)`
answers for what a device DECLARES, not what it reports: `hid-input` allocates
`BTN_A + n` straight through for every button in the descriptor, so BTN_C
(`0x132`) and BTN_Z (`0x135`) are set on ANY pad declaring six or more —
including a standard-layout pad that never presses either. The signal is
therefore identical on the pad that needs correcting and the pad that does not,
and no amount of tightening it could have separated them. It is the same pad
model in both reports: an Elite Series 2 needed the correction on a Fire TV and
another Elite Series 2 was broken by it here.

What does separate them is the axes. A HID gamepad describes its triggers either
as the Accelerator/Brake usages — which become `ABS_GAS`/`ABS_BRAKE`, names
Android has words for — or as two more generic axes on `ABS_Z`/`ABS_RZ`, which
it does not. A descriptor well-formed enough to name its triggers puts its
buttons at the standard positions too. It is also the firmware line on the pad in
the report: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE after
its firmware update and Z/Rz before it, and only the older one was ever wrong.

`padButtons` now takes `namedTriggers` and answers NATIVE whenever it is set —
no correction of any kind, on buttons or axes, for a pad Android already reads.
`padMap` computed that fact one line below and only ever spent it on the axes;
it now decides both. `hasKeys` stays for the narrower question it can answer —
WHICH straight-through order, once the axes have established there is one — where
a false positive costs nothing.

Moonlight decides it on the same fact (`ControllerHandler`, `gasRange == null`
beside the `"Xbox Wireless Controller"` name); yesterday's commit cited its
tables and then replaced its discriminator, which is where this came in.

Verified: `:kit:testDebugUnitTest` and `:app:testDebugUnitTest` green (16 cases
in PadButtonsTest, 3 new: the gate holds for every vendor/declaration
combination, the four reported buttons stay themselves, and the report-order
choice past the gate is unchanged), `:app:compileDebugKotlin` clean. The
DualSense report filed alongside these — Triangle dead in the client UI and in
the stream — is NOT explained by this and is not fixed here: a button that
reaches neither is one `buttonBit` maps to nothing, which no branch of the
correction produces for Triangle. The Controllers screen prints the raw scancode
and keycode of every press; that line off the reporter's pad will pin it.
2026-08-21 13:10:23 +02:00
enricobuehler fc3b2d0328 Merge pull request '0.31.1 notes: fold in #362, which is what makes the console's off switch work' (#364) from worktree-release-0311-foldin into main 2026-08-20 20:37:56 +00:00
enricobuehler 1c1fd7d9bc release: 0.31.1 — fold in #362, which makes the console's off switch work
30 commits since v0.31.0 (19 non-merge). #362 merged onto main seconds
after the release merge (#363), so the notes as tagged would have been
wrong in the one way that matters: they lead `## New` with the console's
"Controller-optimized UI" switch, and cbd3d028 is the commit that makes
that switch do anything at all.

trust::Settings::extra is #[serde(flatten)], so the android.* keys are
TOP-LEVEL keys of the settings document. ConsoleJson wrote and read them
nested under an "extra" object, which serde filed under the literal key
"extra" — so no console row ever found android.gamepad_ui_enabled. Every
Android-only row rode the same dead path in 0.31.0: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.
That is a user-facing fix in its own right and is now in `## Fixed`, in
`## TL;DR`, and in the Play notes — it is the most useful 500 characters
an Android user gets from this release.

Also folded in: c9a76287, the focus halo and panel_highlight taking the
card's corner plus their own outset (a rect grown by d only stays
concentric if its radius grows by d too).

NOT folded in — deliberately: cargo audit is red on main with
RUSTSEC-2026-0258 (h2 0.4.15, unbounded empty DATA frames, fixed in
0.4.16, transitive via hyper). It predates this cut — the same job failed
on 669a1bc0 and on the v0.31.0 tag commit — so it is not a regression
here. `cargo update -p h2 --precise 0.4.16` bumps h2 in eleven lock lines
but ALSO rewrites several windows-sys references downward (0.61.2 ->
0.59.0/0.52.0) on the pinned 1.96.0 toolchain. Re-resolving the graph for
the Windows build inside a version bump that cannot be compiled for
Windows on this host is the wrong trade; it wants its own commit and its
own CI. Recorded in the CHANGELOG's verification section.

Cargo.lock is untouched by this commit.

Gates re-run: cargo fmt --all --check clean; scripts/ci/check-docs-drift.sh
clean; cargo test -p punktfunk-core --lib 273 passed; the Play notes gate
run verbatim, 440/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 22:37:01 +02:00
enricobuehler 1c60e641b3 Merge pull request 'The console's own settings never left the console, and the layer under a card missed its corners' (#362) from worktree-android-console-extra-keys into main 2026-08-20 20:03:42 +00:00
enricobuehler eda4b7ebd2 Merge pull request '0.31.1 — the video data port, the pad games could not see, and the buttons Android guessed wrong' (#363) from worktree-release-0311-prep into main 2026-08-20 20:00:14 +00:00
enricobuehler 730ac43169 release: 0.31.1 — version bump, notes, CHANGELOG, Play notes
27 commits since v0.31.0 (17 non-merge). Cut from origin/main 2be444b3
(#361 merged).

THE NUMBER: a patch, on the user's call, and the version table does not
argue with it. Nothing versioned moved — WIRE_VERSION 2, C ABI 25 with
include/punktfunk_core.h differing from the v0.31.0 tag by one `#define`,
driver protocol 6 / min 3 (pf-driver-proto has no diff against the tag),
gamepad channel 3, plugin index schema 1, host event schema 1, gamescope
+pfhdr8 with no new patch files, SDK 0.1.5 untouched. No `!` commit and
no breaking change of any kind.

Two surfaces grow, both by pure addition, and neither forces the minor:
the management API gains PUT /library/provider/{provider}/running (#361,
already regenerated into both openapi copies there — this commit moves
only the stamp), and plugin-kit goes 0.4.3 -> 0.4.4 to export
ProviderClient.reportRunning from the package root. plugin-kit-v0.4.4 is
tagged AND published — it is the registry's `latest`; 0.4.2 is still
skipped there. A plugin that adopts neither is unaffected, and a 404 from
an older host means "this host tracks games by scanning".

The cycle is otherwise fix-shaped: fifteen fixes and two small features
— the console's own switch for turning itself off (which exists because
0.31.0 made the console the only interface Android shows with a pad
attached) and the provider running-report above.

The new control message (DeliveryReport, type byte 0x0B) is a `#define`,
not an ABI step — the same shape PipelineGap (0x0A) had at v0.30.0, and
it takes a free byte in its block rather than lengthening LossReport,
which is length-checked exactly.

Two behaviour changes are called out in the CHANGELOG rather than left
to be discovered: Windows `service install` now provisions a
program-scoped inbound UDP rule for the host exe (the black-picture
fix — no `localport=` rule can ever cover an ephemeral data port), and
the GameStream plane's default virtual-Xbox backend flips from the XUSB
companion to the UMDF HID pad, which is the correction of a default that
was only ever a module-name coincidence. Both revert with
PUNKTFUNK_XBOX_BACKEND=xusb / by removing the rule.

Also in this commit, because a cut is when it bites:
packaging/gamescope/PKGBUILD moves pfhdr7 -> pfhdr8. The banner has said
+pfhdr8 since patch 0010 and the host probes it for >= 8, but pacman
compares pkgver-pkgrel, read 3.16.25.pfhdr7-1 on both v0.30.0 and
v0.31.0, and offered Arch/SteamOS hosts no upgrade at all — so the
keyboard-layout fix never reached them. deb and rpm derive the version
from the binary banner and moved by themselves; Arch is the only channel
that hardcodes it. The v0.31.0 table flagged this as pre-existing.

api/openapi.json is RE-STAMPED here, not regenerated: punktfunk-host
does not build on macOS. #361 regenerated the document itself, with the
new route and its three schemas, on a runner where
openapi_document_is_complete_and_checked_in actually executes. This
commit moves one line (info.version, which utoipa fills from
CARGO_PKG_VERSION), and "0.31.0" appears nowhere else in either copy.

Gates: cargo fmt --all --check clean; cargo metadata --offline ok with
the Cargo.lock diff versions-only (36/36); cargo test -p punktfunk-core
--lib 273 passed; the C ABI harness passed reporting abi_version=25 —
it did NOT run on the v0.31.0 cut, so this is the first cut since ABI 25
where a C compiler has built the generated header;
scripts/ci/check-docs-drift.sh clean; the android.yml Play notes gate
run verbatim, 442/500 characters and unique; both openapi copies cmp
identical and stamped 0.31.1; notes voice scan clean.
2026-08-20 21:58:39 +02:00
enricobuehler c9a76287d8 fix(console-ui): the layer under a card takes the card's corner plus its own outset
The focus halo grows the card's rect by 4 design units on every side but drew
it with the card's own corner radius. A shape grown by `d` keeps its corners
parallel to the original's only if its radius grows by `d` too — otherwise the
two arcs stop sharing a centre. So the halo came out squarer than the card it
sits under: clean along the edges, visibly misaligned at the four corners,
where it read as a badly drawn outline rather than as light spilling out.

Same rule applied to `panel_highlight`, which pulls in half a unit and kept the
full radius. `drop_shadow` only offsets, so its geometry was already right, and
the collections plate uses `RRect::with_outset`, which adjusts the radii itself.

Every card in the console goes through these two helpers — the home tiles, the
library grid, the coverflow, the collections deck.
2026-08-20 21:44:55 +02:00
enricobuehler cbd3d02817 fix(clients/android): the console's Android rows were nested where serde flattens them
Turning "Controller-optimized UI" off in the console did nothing: the console
stayed up, because the setting never left the console.

`trust::Settings::extra` is `#[serde(flatten)]`, so the `android.*` keys are
TOP-LEVEL keys of the settings document, beside `width` and `codec`.
`ConsoleJson` wrote and read them nested under an `"extra"` object instead.
Serde put that whole object into the map under the literal key `"extra"`, so
no console row ever found `android.gamepad_ui_enabled` — the row showed its
own default, and the value the console saved came back to Kotlin as the one
Kotlin had just sent. `applySettings` then saw no change, raised no callback,
and `App` never recomputed `gamepadUiActive`.

Every Android-only row rode the same broken path: low latency, phone
rumble/gyro, SC2 and DualSense capture, and the console-UI mode picker.

A store written by the nesting build carries the dead wrapper; it is dropped
on the next write rather than echoed for the life of the install.

The new test pins the shape from both sides. A round-trip alone could not have
caught this — both halves agreed on the same wrong nesting, which is exactly
how it survived review.
2026-08-20 21:44:46 +02:00
enricobuehler 2be444b329 Merge pull request 'A provider plugin can report which of its titles are running' (#361) from worktree-playnite-runstate into main
Reviewed-on: unom/punktfunk#361
2026-08-20 19:22:10 +00:00
enricobuehler 669a1bc0ce The video data port was never open, and nothing could tell us (#360)
Merged with `apple / swift` red: the Mac runner's volume is out of space (594 "No space left on device" lines; the only distinct error is "You can't save the file because the volume 'Macintosh HD' is out of space"). It passed on the parent commit 1280f697 and the only delta since was one line in scripts/ci/docs-undocumented-env-baseline.txt — Swift compilation of the Apple client cannot be affected by a Rust host firewall rule, a core control message, or a CI text baseline. A rerun hit the same wall, so the mac-mini-1 disk needs the owed cleanup.

Every check that can validate this change is green: ci/rust (480 core tests), ci/rust-arm64, android, docs-drift, web, docs-site, bun-nix, and both windows-client legs. The Windows HOST build — which the PR checks never run, and which is the only thing that compiles the cfg(windows) firewall function — was dispatched separately on the branch and passed, including "Clippy (host + tray, Windows)".
2026-08-20 18:48:28 +00:00
enricobuehler 8ff6fe6093 fix(host): regenerate the API spec, and stop the runstate tests colliding
`api/openapi.json` (and its docs-site copy) gain exactly the new route and its
three schemas — nothing else moved, which is the check worth doing on a
regenerated spec.

The test fix is one the tests found themselves, on the first run in an
environment that actually executes them: all three shared the provider id
`playnite` and cleared the whole process-global table between cases, so under
parallel scheduling they flipped each other's answers — `omitted_is_not_running`
read `None` for a title another test had just wiped. Each now takes ids only it
uses and forgets only its own row, which also retires the blunt `reset()` that
made the collision possible.
2026-08-20 20:38:08 +02:00
enricobuehler 1758266bda chore(plugin-kit): export the running-report surface, and bump to 0.4.4
`reportRunning` and its two types were reachable only through the deep
`./reconcile.js` path — `index.ts` re-exports an explicit list, not a star — so
no plugin could import them from the package root the way it imports every
other provider symbol.

Version bumped because it is a published package and the addition is what a
consumer would depend on; the playnite plugin deliberately does NOT, calling the
route through the untyped host seam instead so it is not gated on this publish.
2026-08-20 20:38:08 +02:00
enricobuehler d5fb1e4479 feat(host): a provider plugin can report which of its titles are running
The host derives liveness by scanning, which needs something recognizable on
disk. A Playnite-launched emulated game, a manually added one, or a library
plugin that records no install directory has none — and its launch is a
`playnite://` hand-off, so the host holds no process either. The lease went
`Untracked`: the exit was never noticed, `session_on_game_exit` could not fire,
and `POST /game/end` had nothing to aim at. Playnite knew the whole time.

New `PUT /library/provider/{provider}/running` takes a provider's complete
running set (with the pid where it knows one) — declarative and idempotent like
the reconcile beside it, so a missed event or a plugin restart self-corrects
rather than drifting. `crate::runstate` holds it and expires it after 90s
unless restated, which is what makes it safe for a live provider to hold a
session open for a game the host cannot see: a plugin that dies stops counting
and the host falls back to scanning, exactly as today.

`LeaseKind::Reported` is the lease that follows from it. `open` reaches it when
the spec is empty and a provider speaks for the id, and — the load-bearing part
on Windows, where every launch is a hand-off by construction — the three shim
reclassification paths now fall back to it where they fell to `Untracked`.
Phase 1 takes "running" as the game appearing; phase 2 takes "stopped" as the
exit. Unlike `procscan::running_hint`, which may only ever delay an exit because
Steam's registry flag survives an unclean one, a fresh report is decisive in
both directions. A reported pid joins the termination ladders on the same terms
as a spawned one: re-resolved and start-time-pinned at the moment of use.

The route is the plugin lane's, like the reconcile. No new authority — the host
maps `external_id` through the catalog, so a provider can only speak about
entries it published; an unknown id is counted, not refused, because a report
legitimately races its own reconcile and 400-ing the batch would throw away the
liveness of every other running title.

plugin-kit gains `ProviderClient.reportRunning`; a 404 from an older host means
"this host tracks games by scanning".
2026-08-20 20:38:08 +02:00
enricobuehler ea3c9e1202 ci(docs-drift): baseline the new control-message constant
`check-docs-drift.sh` scans for `PUNKTFUNK_*` identifiers and asks that
each be documented in docs-site or explicitly baselined. It cannot tell
an env knob from a cbindgen-exported `#define`, so the new
`PUNKTFUNK_MSG_DELIVERY_REPORT` header constant tripped it.

It is a wire message type byte, not a knob an operator can set — same as
every other `PUNKTFUNK_MSG_*`, all of which are already in the baseline.
Added in sorted position beside them.
2026-08-20 20:27:37 +02:00
enricobuehler 685c4bd99a Merge pull request 'A pad that was never there kept the console UI on, and the picture sat in the corner' (#359) from worktree-android-gamepad-ui-and-corner into main 2026-08-20 18:22:33 +00:00
enricobuehler 0519b057d5 fix(clients/android): a pad that was never there, and a picture in the corner
Two field reports from one Android user, with one shape between them: a
decision taken once, at a moment when the answer was still wrong, and never
revisited.

The console UI could not be dismissed. "With a controller" asks whether a pad
is attached, and the client answered that with `isPad` — does this device's
source class include gamepad or joystick. That is the right question for
ROUTING an event and the wrong one for presence: devices publish inputs that
claim the source class while being no such thing (OEM game-mode overlays, the
gaming-phone shoulder triggers), and one of them is enough to pin the console
UI on forever, because a pad that was never there can never disconnect.

`pads()` now filters on `looksLikeController`: the source claim AND hardware
behind it — a stick, a HAT, or the A/B face buttons — on a device the platform
did not synthesize itself. The claim is cheap; the hardware is not. `isPad`
keeps its looser meaning for the event lane, where it is correct. It is not a
complete defence (an OEM device that declares BTN_GAMEPAD and two axes is
indistinguishable from a pad at this layer), so the master switch stays the
guaranteed way out — and the Controllers screen still lists everything real in
one column or the other, which is where someone looks when the client's idea of
"a pad is attached" disagrees with the room.

The picture sat in the top-left corner. The ASurfaceControl layer composites
into the SurfaceView's on-screen rectangle, read once at `surfaceCreated` — but
the stream screen hides the system bars and switches the window to draw into
the display cutout a frame or two later, and each of those grows the view under
a surface that is never recreated. The layer went on painting at the size it
started with, anchored at the origin. It passed on glass because a device whose
bars were already hidden when the surface arrived never sees the gap.

The size is now live: a packed atomic on the session handle, seeded by
`nativeStartVideo`, re-reported by `nativeVideoSurfaceSize` from every
`surfaceChanged`, and read by the layer before each present. One atomic load per
frame, and rotation and multi-window come along for free.

Verified: `:kit:cargoNdkClippy` (arm64 + armv7, deny warnings), `:kit:` and
`:app:` unit tests, and the native crate's own suite. The new JNI symbol is
exported in the built `.so`.
2026-08-20 20:07:57 +02:00
enricobuehler 33b029695f Merge pull request 'The gamescope prune deleted the compositor and shipped only its launcher' (#355) from gamescope-show-version-banner into main
Reviewed-on: unom/punktfunk#355
2026-08-20 18:00:07 +00:00
enricobuehler 1f6f01cb76 Merge pull request 'The in-stream "Switch to Desktop" went unwatched on Nobara, and both distro families leaked an idled Game Mode' (#358) from worktree-nobara-gamemode-switch into main 2026-08-20 17:51:44 +00:00
enricobuehler fade2f7af3 Merge remote-tracking branch 'origin/main' into worktree-nobara-gamemode-switch 2026-08-20 19:48:15 +02:00
enricobuehler 6e4cc335c5 Merge pull request 'The console can switch itself off, and a TV remote can reach everything it offers' (#354) from worktree-console-ui-off-toggle into main
Reviewed-on: unom/punktfunk#354
2026-08-20 17:46:35 +00:00
enricobuehler d9662c010d fix(gamescope): 0.31.0's idled takeover left nothing watching for "Switch to Desktop"
The managed takeover stopped stopping the display manager and started idling the
box's autologin session instead (c2f5e91b). That commit also deleted the two lines
the old DM stop carried — `record_session_select_baseline()` and
`STOPPED_DM = Some(dm)` — and 38a0f54b then removed every remaining writer of that
static. The in-stream switch gate in `create_managed_session` still reads it, so
`honor_session_select_switch` became unreachable code on every box.

Bazzite never noticed, for two reasons measured on `.41`: its `os-session-select`
is a thin wrapper over `steamosctl` D-Bus calls and writes NO sentinel (verified —
`~/.config/steamos-session-select` does not exist before or after a completed
switch), and `is_steam_htpc_platform()` defaults the mid-stream session watcher ON
for bazzite/steamos, which follows the switch by itself.

Nobara gets neither. `ID=nobara` matches no HTPC default, so no watcher; and its
ChimeraOS-layout `os-session-select` DOES write the sentinel, which was the only
thing the host had to see the switch by. So on Nobara the user's "Switch to
Desktop" went entirely unhandled: the capture loss it causes drives a rebuild, the
rebuild relaunches game mode over the booting desktop, and the stream is thrown
straight back in — the 2026-07-24 field report, reopened by a user 2026-08-20.

Arm the gate on the takeover that actually exists now. `takeover_idled()` reads
IDLE_DROPIN_ARMED, the idle drop-in re-baselines the sentinel when it goes in, and
STOPPED_DM is documented as what it has become: adoption-only state for a takeover
stranded by a pre-0.31.0 host. The hand-back keeps that host's DM restore + switch
replay (now `replay_switch_under_restored_dm`) but runs it only for such an adopted
takeover — a 0.31.0 takeover leaves the DM up precisely so the OS does that work
itself, and on the VM it does: `Updated user selected session to plasma`, then
Plasma.

Both hand-back paths also give the box its own Game Mode back, which neither did.
The takeover replaces that session's ExecStart with a sleep, and a mid-stream
switch is the one exit that leaked it — the disconnect restore sweeps it, but a
switch is not a disconnect. Measured on Bazzite `.41`: after a completed switch to
KDE the unit still carried `ExecStart=/usr/bin/sleep infinity`, so "Return to
Gaming Mode" would have started a unit that does nothing. That is the same barred
way back `release_autologin_mask` already exists to prevent for the mask this
drop-in replaced, so it goes there — covering the watcher-driven distros — and in
`honor_session_select_switch`, covering the sentinel-driven ones.

Verified on the Nobara VM (123 on home-node-3) by driving the real path against
live systemd: takeover -> real `steamos-session-select plasma` -> rebuild. The
rebuild refuses to relaunch game mode, the idle drop-in is gone, and the box
arrives in KDE. Backed out to the 0.31.0 shape on the same box it fails at "the
switch did not advance the sentinel" — the blindness itself. The extended
`the_mask_comes_off_only_when_the_box_takes_itself_back` covers the watcher path
against a live user manager, and fails without the hand-back.
2026-08-20 19:33:19 +02:00
enricobuehler 2b0913cf53 Merge pull request 'A DualSense and an Elite Series 2 on a Fire TV pressed each other's buttons — Android reads the scancode now' (#357) from worktree-android-pad-hid-remap into main 2026-08-20 17:18:46 +00:00
enricobuehler 1280f697be fix(host,core): the video data port was never open, and nothing could tell
A field host streamed 1919 frames into a black screen while its own log
blamed the client. Four faults, each of which alone makes the failure
invisible.

The Windows firewall rules are `localport=`-scoped (47998-48010, 9777,
5353), but the media data plane binds an EPHEMERAL port per session. No
such rule can ever cover it, so Windows Firewall drops the client's
hole-punch on EVERY session on EVERY Windows host — `punched=false` on
the "data plane bound" line, in all six sessions of two field logs. The
punch then never opens the return path and video is sent blind to an
address the client merely reported. `service install` now also adds a
program-scoped inbound UDP rule for the host executable, which covers
whatever port a session picks. Program-scoped rather than a pinned port:
pinning into 47998-48010 would collide with Sunshine/Apollo.

`LossReport` carried only `loss_ppm`, which is ambiguous at zero — loss
is a ratio over the packets that arrived, so a flawless link and a link
delivering NOTHING both report 0. The host read total silence as perfect
and decayed adaptive FEC to its floor. Clients now also send a
`DeliveryReport` with the session's received-packet count. It is a new
type byte, NOT a field appended to `LossReport`: that message is
length-checked exactly, so lengthening it would make every shipped host
reject the loss reports its FEC runs on. Sent every window while the
count is zero, once when the first packets land, then never — an older
host warns per unknown message and must not be flooded on a good
session.

`NO_VIDEO_RETRY` (client got nothing) and `FLUSH_COOLDOWN` (client
drowning) were both 2000 ms, so the host's cadence classifier could not
tell two opposite faults apart and picked the wrong one out loud. The
no-video cooldown moves to core beside `FLUSH_COOLDOWN` at 2600 ms, and
both sides compare against the shared constant rather than a copy.

The diagnosis now leads with the delivery count: zero is an error naming
the data plane, a confirmed count keeps the old confident wording, and an
old client that cannot answer gets a warning that says so instead of
guessing. A punch that never arrives is also its own warning now, rather
than a debug field on an info line.
2026-08-20 19:14:49 +02:00
enricobuehler 3b39710a5a fix(clients/android): controllers Android has no key layout for stop landing on the wrong buttons
Reported from a Fire TV Stick 4K Max: a DualSense and an Xbox Elite Series 2,
both over Bluetooth, both identified correctly but with buttons on the wrong
actions ("L1 being L2"). Both work in Moonlight on the same box.

Android names a pad's buttons through a KEY LAYOUT file matched on VID/PID. A
pad with no matching file falls back to AOSP's `Generic.kl`, which assigns
keycodes by SCANCODE POSITION — 0x130→BUTTON_A, 0x131→BUTTON_B, 0x132→BUTTON_C
and up. That is only right if the pad's buttons sit where the file assumes, and
a HID gamepad with no kernel driver numbers its buttons 1..n straight through in
its OWN report order, so every keycode past the first divergence is somebody
else's button. Neither pad has a layout there: AOSP ships none for the Elite
Series 2 over Bluetooth (045e:0b05) on any version, and the DualSense's
(054c:0ce6) both postdates Fire OS and carries `requires_kernel_config
CONFIG_HID_PLAYSTATION`, which a Fire TV kernel has not. A DualSense reporting
straight through puts L2 on 0x136, which `Generic.kl` calls BUTTON_L1 — the
reported symptom exactly, and Circle and R1 (BUTTON_C / BUTTON_Z) mapped to
nothing at all and were dropped.

This client read `KeyEvent.keyCode` only, so it inherited every one of those
errors — in the stream, in the console shell's navigation, and in the
Controllers screen's own tester, which is why the tester agreed with the bug.

Resolve buttons from the SCANCODE instead, which is the pad's own report
position and immune to the layout file — the same reason `Keymap.toVk` reads
`scanCode` for keyboards. Two things keep it off pads that already work:

  1. the correction applies ONLY where the delivered keycode is what
     `Generic.kl` would have said, so a device-specific layout always wins;
  2. which report order to read is decided from what the DEVICE declares —
     a pad numbering straight through claims BUTTON_C and BUTTON_Z, keycodes
     no real controller has a button for — never from a model table.

`Gamepad.padKeyCode(event)` is a drop-in for `event.keyCode` and every pad
reader now goes through it: the streaming branch, the Skia console shell's
probe, the older Compose navigation, and the Controllers tester. The console and
the game must not disagree about which button a user pressed.

Axes get the same treatment: a pad declaring none of LTRIGGER/RTRIGGER,
BRAKE/GAS or BRAKE/THROTTLE is one Android never mapped, and its triggers are on
raw axes. A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry, so
only the triggers (RX/RY) were missed; every other such pad puts the right stick
on Rx/Ry and the triggers on Z/Rz — the shape that makes pulling a trigger swing
the right stick. Whether those idle at −1 is measured off the device's own range
rather than assumed, so a pad reporting an honest 0..1 is not rescaled to a
permanent half-pull.

Also: the Controllers screen now reports each press as its raw scancode and
keycode plus the correction, names the resolved layout on the pad's card, and
reads axes through the same map the stream does — so it can be trusted again,
and so an unmapped pad can be pinned from a field report without the hardware.
The Xbox Bluetooth product ids (One S, Elite Series 2 and its Core) join the
identity table; without them an Elite streamed under the Xbox 360 identity.

Moonlight carries the same two report-order tables (`ControllerHandler`'s
`isNonStandardDualShock4` / `isNonStandardXboxBtController`), which is why both
pads work there on the same box.

Verified: :kit:testDebugUnitTest and :app:testDebugUnitTest both green (13 new
cases in PadButtonsTest covering the scancode tables and the axis resolution),
:app:compileDebugKotlin clean. NOT yet tested on glass — the reporter's Fire TV
is the confirmation this needs. The Xbox pad's Guide button stays unreachable
either way: it arrives as KEY_HOMEPAGE, and Android never delivers KEYCODE_HOME
to an app.
2026-08-20 18:46:56 +02:00
enricobuehler 19243c30b4 Merge pull request 'GameStream sessions built the XUSB pad that Steam cannot see — Xbox backend parity with the native plane' (#356) from worktree-gamestream-xbox-hid-parity into main 2026-08-20 16:33:41 +00:00
enricobuehler dfcffcdd50 fix(gamestream/windows): Moonlight sessions built the XUSB pad that Steam cannot see
Reported from the field: gamepad input does nothing on GameStream clients, and it
reproduces across every client and device a user tries (Artemis on a Steam Deck and
on an Android phone both). That breadth is the tell — it is not a client at all.

On Windows there are two virtual Xbox backends and they are not interchangeable to a
game. The XUSB companion registers only GUID_DEVINTERFACE_XUSB and exposes no HID
collection (pf_xusb.inx says so in its own header: "a non-HID UMDF2 driver", Class =
System), so Steam's hidapi enumeration, SDL, RawInput, DirectInput, joy.cpl and
WGI/GameInput cannot see it at all — only classic XInputGetState can. The native plane
moved to the real HID pad as its DEFAULT on 2026-08-09 (bd5735b8) for exactly that
reason; its doc comment records the reporter who lost a controller for two weeks to it.

The GameStream plane never got that change. It has bound `crate::inject::gamepad`
since the first gamepad commit, when that name meant uinput and Windows had no second
backend; Windows later gave the same name the XUSB companion, so this plane inherited
it by module-name coincidence rather than by any decision. bd5735b8 did touch
control.rs — but only to widen the rumble closure's arity, and its note reasons about
"the uinput backend", not about the Windows one sitting behind the same import. So
every Moonlight session since has presented a pad most games cannot enumerate, while
native punktfunk sessions on the same host got the good one.

There was also no way out: `windows_xbox_hid` was `pub(super)`, i.e. unreachable from
this module, so PUNKTFUNK_XBOX_BACKEND did nothing here — and the Windows manager
ignores the arrival `kind` byte, so a client could not ask for a DualSense either, the
escape that saved the earlier reporter.

- native.rs / native/gamepad.rs: `mod gamepad` and `windows_xbox_hid` become
  pub(crate). The knob keeps ONE definition and one name; widening visibility breaks
  no existing caller. Its doc now records that both planes read it, and why being
  `pub(super)` was itself the bug.
- gamestream/control.rs: a `SessionPads` enum is the one place this plane picks a
  backend — the HID pad when the shared knob says so, the XUSB companion otherwise,
  and on every other platform the single backend that exists.
  `PUNKTFUNK_XBOX_BACKEND=xusb` now reverts both planes together.

The HID pad's rich-feedback plane is dropped rather than plumbed: an Xbox pad has no
lightbar or adaptive triggers, and GameStream has no vocabulary for one — its rumble
message (0x010B) carries the two handle motors and nothing else, which is why the
trigger levels were already dropped at the call site.

No test: a `cfg(target_os = "windows")` #[test] would compile NOWHERE. ci.yml excludes
it by target, and windows-host.yml lints the host with `cargo clippy -p punktfunk-host`
without `--all-targets`, which does not build `cfg(test)` modules at all — the same
blind spot that workflow's own comments blame for letting the Linux twin's tests rot to
the wrong arity. It would be dead weight, not coverage.

VERIFIED
  * ON WINDOWS (.133, the only box where this arm compiles at all):
    `cargo check -p punktfunk-host` clean in 57s, then
    `cargo clippy -p punktfunk-host -- -D warnings` clean in 42s — both on default
    features, which include `gamestream`. This compiles BOTH enum arms: `Xusb` and
    `Hid` are Windows types alike.
    Non-vacuous by construction: `cargo clean -p punktfunk-host` ran first (a scp'd
    tree plus this box's lagging clock and a warm shared target dir otherwise yield a
    fresh `Finished` having compiled nothing), and both passes logged
    `Compiling punktfunk-host v0.31.0`. The only warning is punktfunk-core's
    header-write notice — benign and pre-existing.
  * `cargo fmt --all --check` clean.

NOT VERIFIED — owed
  * Linux: no box could run it (.25's root filesystem is 100% full; .21/.136/.138 were
    down; OrbStack here hangs even on `docker run alpine echo`). Exposure is small —
    the Linux arm is three calls on a `GamepadManager` whose Windows XUSB sibling has
    the identical method surface and just compiled — and ci.yml DOES run on
    pull_request, so opening a PR closes this.
  * On glass: no Moonlight session has driven the HID pad through this plane yet. That
    is the real acceptance test, and the log line to look for is
    "virtual Xbox pad created (Windows UMDF HID)" where it used to say
    "virtual Xbox 360 created (Windows XUSB companion)".
  * windows-host.yml has NO `pull_request` trigger (push to main, v* tags,
    workflow_dispatch only), so CI will not re-check this arm on a PR — the .133 run
    above is deliberately standing in for it.
2026-08-20 18:19:19 +02:00
enricobuehler f2b5b3e567 Merge pull request '0.31.0 release notes, re-cut on current main — and the bun install CI flake' (#351) from worktree-release-next-prep into main 2026-08-20 07:50:58 +00:00
enricobuehler 6d7e6f71c0 release: 0.31.0 — verification pass before the tag
Re-measured every version-table row against the tree rather than
trusting the earlier cut: ABI 25, wire 2, driver 6/min 3, gamepad 3,
index schema 1, event schema 1, edition 2024, MSRV 1.85, 27 crate dirs,
39 workspace members, SDK 0.1.5, plugin-kit 0.4.3, openapi 0.31.0 with
both copies byte-identical, gamescope +pfhdr8 with 10 patch files and
no diff against the tag. All match what the table claims. The C header
diff against v0.30.0 removes exactly two things — the old ABI define and
the ex11 doc paragraph that stated the superseded hi-res rule — so
'addition only' holds.

Three fixes to the text itself:

  * The commit count is now taken at the tip being released (170/113 at
    d7fa5847) instead of at this branch, where it moved every time I
    committed to it.

  * The two NixOS items in Before-you-update are one item. They were
    separated by three unrelated bullets, so the audience that has to
    act on both — builds are failing AND there is now a cache — had to
    find them in two places.

  * A TL;DR line read 'it repaired itself on no retry', which says the
    opposite of what it means.

Checked and left alone: the Gaming Mode 'Switch to Desktop' entry is
presented as a bug users hit, and that is right — v0.30.0's takeover
does stop the display manager on mask-fragile flavors
(gamescope.rs:259 at the tag), so it shipped. Only the mask-vs-stop
detail moved inside this cycle.

Gates: fmt clean, punktfunk-core --lib 273 passed, cargo metadata ok,
Play notes 456/500 and unique, voice scan finds no internal names in the
notes body.
2026-08-20 09:42:02 +02:00
enricobuehler 9e3fba10c1 release: 0.31.0 — fold in the four commits that landed during the re-cut
170 -> 173 commits since v0.30.0 (115 non-merge, counted on this branch).

One is user-facing and is in the notes: the web console's Virtual
displays page rendered the Streamed-screen and session-lifetime cards
below the tab shell, so both tabs showed them and the Live tab read as
a duplicate of Configuration. They are policy surfaces and now sit
inside Configuration.

The other three are CHANGELOG-only: the guides trim's second round, the
fifth get-started screenshot (a client's host list), and the workflow
change that made it reachable — linux-client-screenshots now publishes
its PNGs to the generic registry as well, because the v3 artifact store
is browser-only and nothing could pull the shots out of it.

The CI bullet also records this branch's own retry.sh wrapping, since a
reader hitting 'Fail extracting tarball' deserves to find the diagnosis
rather than repeat it.

Gates re-run on this tip: fmt clean, punktfunk-core --lib 273 passed,
Play notes 456/500, both openapi copies identical at 0.31.0.
2026-08-20 09:36:58 +02:00
enricobuehler fd4f032d20 ci: retry bun install — a truncated tarball reads as a corrupt package
docs-site died on `error: Fail extracting tarball for
"@rolldown/binding-linux-x64-gnu"` (run 19630, 2026-08-20). The message
points at the package; the package is fine.

MEASURED, because the message invites the wrong fix:
  * The tarball's sha512 matches docs-site/bun.lock exactly, and it is
    an ordinary 3-entry npm tgz — same gzip framing, same modes, no pax
    headers — as the 1.2.0 one that installs fine. Only the payload
    differs in size (20.6 MB vs 19.0 MB of .node).
  * bun 1.3.13 AND 1.3.14 both extract that exact tarball from disk in
    under 80 ms. So it is not the bun bump the floating oven/bun:1 tag
    brought in, and not a format bun stopped accepting.
  * In the SAME run, the web job installed the same registry over the
    same network and passed — it was 25 s ahead of docs-site.
  * Run 19632, seven minutes later, installed the identical lockfile
    and passed.

So: a transient truncation, not a bad package. bun streams
download-and-extract, so a tarball cut off mid-stream surfaces at the
extract step and names the package it was reading — which is why this
looks like `@rolldown/binding-linux-x64-gnu` is broken and why the
obvious fixes (bump rolldown, pin bun, refresh the lockfile) would all
have "worked" by changing which bytes were in flight, and none of them
would have fixed anything.

scripts/ci/retry.sh already exists for precisely this and its header
already diagnosed it: "the runner box executes many jobs in parallel and
its network drops packets under that load … Wrap every single-shot
network command in CI with this instead." `bun install` is a single-shot
network command and was the one class still unwrapped, so it is wrapped
now at all nine Linux sites — ci.yml (web, docs-site), arch, deb, rpm,
web-screenshots, sdk-publish and plugin-kit-publish (both installs).

3 attempts, not retry.sh's usual 5: a genuinely stale lockfile fails
deterministically under --frozen-lockfile, and 10s+20s of backoff is
enough to outlive a load burst without making that honest failure wait
a minute and a half.

The two windows-host.yml installs are left alone: pwsh, and a Windows
box that is not the contended runner.

Verified: all seven workflows still parse; the helper resolves from
web/, docs-site/ and sdk/ (the three working-directory shapes used);
the wrapper recovers a command that fails once and succeeds on the
retry; and `bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile
--ignore-scripts` in docs-site installs all 1138 packages, so the
lockfile is sound and the wrapper does not change the command.

Not done, deliberately: docs-site's lockfile still pins rolldown 1.1.2
where web has 1.2.0. That difference is real but it is not this bug,
and refreshing a lockfile to chase a network flake would have buried it.
2026-08-20 09:34:50 +02:00
enricobuehler 892e683f0e Merge remote-tracking branch 'origin/main' into worktree-release-next-prep 2026-08-20 09:24:01 +02:00
enricobuehler 7af6c323d0 Merge remote-tracking branch 'origin/main' into worktree-console-ui-off-toggle
# Conflicts:
#	docs-site/content/docs/client-settings.md
2026-08-20 09:13:10 +02:00
enricobuehler 76e6618b84 fix(console-ui): Settings and the library are reachable without face buttons, for a TV remote
An Android TV's only input is its remote — a D-pad, OK and Back — and the
console bound both Settings and the game library to face buttons that
remote does not have. X opened Settings, Y opened the shelf, and neither
event could be produced: the Kotlin bridge emits Move/Confirm/Back for a
remote and nothing else, the carousel's only action tiles are Add Host and
Rescan, and the hint chips that also carry those actions need a finger on
a touchscreen. So on a TV the two were simply unreachable. This is the
gap the deleted Compose home used to paper over with its own "no face
buttons" mapping, and the same wall Apple hit on the Siri Remote.

Down on the carousel now opens Settings — the other free direction beside
up, which already opens the host menu. It is a second route on a pad and
the only route on a remote, so the hint bar names the one the device in
hand actually has: ▼ with no pad attached, X with one. That is the whole
extent of the legend change; Y still opens the library and still says so.

The shelf gains a Library row on the host's own options menu, which up
reaches, offered on exactly the terms Y offers it (saved AND paired). It
replaces the menu rather than stacking on it, so Back from the shelf lands
on the carousel and not on a menu about the host just left. This is also
the route `docs/game-library.md` has been describing all along — "open its
options and choose Library" — which until now did not exist.

▼ is a new hint glyph: the ▲ triangle stood on its head rather than a
second drawing routine, and pressable in the hint bar exactly as ▲ is.
2026-08-20 09:06:05 +02:00
enricobuehler 7d2a8778d1 fix(nix): the prune deleted the compositor and shipped only its launcher
ROOT CAUSE of every gamescope symptom chased today. nixpkgs WRAPS this package:
makeWrapper leaves the real compositor ELF at bin/.gamescope-wrapped and
installs a small launcher at bin/gamescope that sets PATH (xwininfo) before
exec'ing it. Our prune kept only `gamescope`:

    find $out/bin -mindepth 1 ! -name gamescope -delete

so it deleted the compositor and shipped the launcher pointing at a path that
no longer existed. MEASURED (run 19622): $out/bin held one 16 KB file, and
`strings` on it showed the PATH prologue and '/bin/.gamescope-wrapped'.

That single line explains all of it:
  * `--version` printed NOTHING — the launcher exec'd a deleted target. I had
    attributed this to the build sandbox, and to upstream's PrintVersion()
    landing after 3.16.25. Both were wrong.
  * the +pfhdr marker was 'missing from the binary' — a wrapper carries no
    version string, so it never could have been there.

Keep the target through the prune, and assert on the WRAPPED ELF rather than
the launcher (which would otherwise pass or fail by accident). The launcher
references its target by absolute path, so renaming the launcher stays safe.

The guards were right on every run; they were reporting a genuinely broken
artifact, not a flaky check.
2026-08-20 07:02:22 +00:00
enricobuehler 6007bc42cd docs(client-settings): the console-off switch is in both places on Android, and neither row is offered on a TV
The Interface section owned two facts this changes: which surfaces carry
"Gamepad-optimized browsing" (the Android console home carries it now, so
that home can be left from inside it) and what an Android TV sees — the
mode row used to be described as present-but-inert there, and both rows are
simply absent now.
2026-08-20 08:40:59 +02:00
enricobuehler 16d54b73a1 feat(console-ui): the console can switch itself off, where there is a touch UI to switch off to
The Android console's settings screen had a picker for WHEN it fronts the
app but no switch for WHETHER it does — that lived only in the touch
settings, which a user already inside the console has no way to reach.
The console UI became the only UI on Android, so the way back out was a
setting you could only change from the thing you were trying to get to.

Adds the "Controller-optimized UI" row over the same `gamepadUiEnabled`
the touch switch writes, folded through `extra` under
`android.gamepad_ui_enabled` like the other Android-only rows. Off
returns to the touch home immediately: the console's save bumps the
snapshot generation, the host emits its settings event, and Kotlin's
`applySettings` now folds the flag back into the App state that
`gamepadUiActive` reads.

Shown only where "off" has somewhere to land. `ConsoleOptions.fallback_ui`
(new, threaded to `Ctx` beside `deck`) is true only for the Android touch
shell — false on a TV and for the desktop session, where this console IS
the interface and an off switch would strand the user in nothing.

The mode row follows the same rule and moves under it: it decides nothing
on a TV (the tv term satisfies `gamepadUiActive`'s OR on its own) or while
the switch is off, so it is hidden in both cases rather than offered as a
control that changes nothing. Renamed to the touch screen's own word for
it, "Show it" — "Controller UI" directly under "Controller-optimized UI"
was two rows told apart by their tails.
2026-08-20 08:40:59 +02:00
enricobuehler d60b1dda29 fix(nix): split the +pfhdr failure into its two possible stages
The marker is not in the installed ELF, and patch 0005 applies cleanly to a
src/meson.build that still carries the exact line it rewrites. Those two facts
cannot both be reasoned from the log as it stands, so stop guessing and
instrument the two stages separately:

  * postPatch now asserts +pfhdr is in src/meson.build after our vcs_tag
    substitution — the same gate build-punktfunk-gamescope.sh applies. If it
    fires, patch 0005 or the substitution lost the marker, and it prints the
    version block as patched.
  * the installCheck failure branch now prints evidence instead of an
    assertion: $out/bin, anything under $out mentioning pfhdr, and the
    version-shaped strings actually present in the binary.

Whichever fires, the next run says which stage drops it. Each guess here costs
a full compositor build, and I have now spent three on this one symptom.
2026-08-20 06:39:28 +00:00
enricobuehler d7fa5847f1 Merge pull request 'Virtual displays: the Streamed-screen and session⇄game cards showed on both tabs — they join the Configuration tab' (#353) from worktree-displays-tab-dup into main 2026-08-20 06:38:08 +00:00
enricobuehler e473a4be7b fix(web): the Virtual displays page's trailing cards join the Configuration tab
The Streamed screen and session-game lifetime cards sat BELOW the tab
shell, so they rendered on both tabs — from the Live displays tab the
page read as the Configuration tab's content duplicated (reported the
other way round, same sight). Both are policy surfaces, so they ride
inside the Configuration tab as siblings of the config card; the Live
tab is now only the live list + arrangement.
2026-08-20 08:34:05 +02:00
enricobuehler 675030935a fix(nix): assert +pfhdr in the installed binary, not by running --version
`--version` produces EMPTY output under nix's build sandbox — measured on BOTH
nixpkgs' 3.16.25 and the pinned 5fb8dce4 (runs 19551 / 19573 / 19594). It is a
sandbox property, not a defect: gamescope calls PrintVersion() before the getopt
loop (src/main.cpp:721), so `gamescope --version` does print the banner on a
real system, which is what the host's capability probe reads. My earlier claim
that the probe itself was broken was wrong.

packaging/gamescope/build-punktfunk-gamescope.sh already avoids running the
binary, asserting on src/meson.build instead. Grepping the installed ELF is
strictly stronger than either: the version string reaches .rodata via
GamescopeVersion.h's k_szGamescopeVersion, so this proves the marker survived
patching, meson configuration and compilation into the artifact we ship — and it
cannot be defeated by the binary being unable to start.

The src pin from the previous commit stays. It is still right for its own
reason: the patches are authored against 5fb8dce4, nixpkgs' 3.16.24 could not
take patch 0009 at all, and every other channel ships this exact commit.
2026-08-20 06:21:37 +00:00
enricobuehler 21d9190324 Merge pull request 'The fifth get-started screenshot — a client's host list — and the workflow change that made it reachable' (#352) from docs-host-list-shot into main 2026-08-20 06:19:35 +00:00
enricobuehler 7ae8866a5c Merge pull request 'Guides trim round 2: the five biggest pages each shed one prose category — −16%, no facts, anchors stable' (#350) from docs-guides-trim-round2 into main 2026-08-20 06:19:20 +00:00
enricobuehlerandClaude Fable 5 a8099e0f5b docs(quickstart): the client host list — the fifth get-started screenshot, pulled from the pipeline the previous commit teaches to publish
Saved hosts with their pairing state + an unpaired host found on the network, from
linux-client-screenshots run 19593 via the new generic-package publish (fixed 'ci' version,
anonymous GET), cropped to the cards. Completes the handoff's screenshot list: console login,
Waiting for approval + PIN, the Approve dialog, live status, and a client's host list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:25:44 +02:00
enricobuehler b4b24f8b57 release: 0.31.0 — re-cut on d801cb72, folding in the 41 commits since #320
#320 merged but v0.31.0 was never tagged, and 69 more commits (41
non-merge) landed on top of it. The version stays 0.31.0 and Cargo.toml
does not move; the notes, Play notes and CHANGELOG section are
re-measured on the new tip.

THE NUMBER IS NOW FORCED, where the first cut's was a judgement call.
The C ABI moves 24 -> 25: punktfunk_set_log_callback plus the
PunktfunkLogCb typedef (#338's core half), so an embedder can hear the
core's tracing lines. Addition only — no declaration moved, no repr(C)
struct grew a field, and an embedder that never calls it is byte-
compatible with v24. include/punktfunk_core.h is therefore NO LONGER
byte-identical to the v0.30.0 tag, which the first cut's table and both
"nothing versioned moves" paragraphs claimed.

Table re-measured on d801cb72. Two rows changed since the first cut and
two were already corrected in-tree by their own commits:
  * C ABI 24 -> 25 (above).
  * api/openapi.json 0.29.0 -> 0.31.0 — the STAMP only. No management
    surface moved; the file had been left two versions stale and #337's
    regenerate-and-diff caught it. Both copies cmp identical.
  * SDK 0.1.5 and plugin-kit 0.4.3 were cut during the cycle (#348,
    #334), so the rows the first cut wrote as "owed" are now past tense.
    The registry still skips 0.4.2 on purpose.
Everything else holds: wire 2, driver protocol 6 / min 3, gamepad
channel 3, plugin index schema 1, host event schema 1, edition 2024,
MSRV 1.85, 27 crate dirs, gamescope +pfhdr8 with no new patches. The
PKGBUILD pfhdr7 discrepancy is unchanged and still pre-existing.

REWRITTEN RATHER THAN APPENDED TO, because the intervening work never
shipped and a user must not read a within-cycle correction as a second
bug:
  * The Gaming Mode takeover section. The first cut described "stops the
    login manager first, then steps in" — which #342 replaced before
    shipping, after a field report that a stopped display manager leaves
    the box unable to service Steam's own Switch to Desktop (it hung
    until reboot, and could not even be detected around: every trace of
    that switch is written by the component we had stopped). End state:
    idle the autologin over the gamescope-session-plus@ template, leave
    the DM running. #343 then deleted the whole 142-line stop/linger
    chain the old approach needed. Two shipped facts became false with
    it — the takeover no longer stops the DM and no longer needs the
    punktfunk group (that group still gates the usbip pad nodes) — and
    the notes' Before-you-update says so for Deck/Bazzite operators.
  * The Android ASC entries. HDR seeded from the codec's echo, untagged
    SDR raising blacks to grey, and the console's dispose re-showing the
    system bars over the stream are all regressions against THIS cycle's
    unreleased presenter, so they are folded into the Android story, not
    listed as fixes users hit.

NEW USER-FACING WORK, in rough order of how much it changes:
  * Windows client (#349): the default download becomes a signed
    per-user Inno Setup installer + portable zip; MSIX stays for the
    Store. This is the Steam fix — MSIX puts the exe under WindowsApps,
    which Steam's picker cannot browse, and alias activation defeats
    overlay injection. Before-you-update carries the one action in the
    release: an MSIX user who wants it must Remove-AppxPackage first and
    will re-pair once, because pairing identity lives with the package.
  * install.sh (#345/#346): one command from nothing to a pairable Linux
    host, running platforms.json's lines verbatim, with Sunshine
    coexistence, --dry-run, --uninstall and env twins. Shipped PREVIEW
    on purpose and the notes say so.
  * Send logs to host completes on Apple (#338) and Android (#339) — the
    two legs 0.30's notes named as follow-ups.
  * KWin 6.6 (#344/#331): it refuses to stream an output it created
    disabled, in the session's language, logged nowhere, and persists
    the disable against the stable per-client name so it repeats
    forever. Enable and retry; and a translated refusal no longer burns
    all 8 retries.
  * Deck Wake-on-LAN (#347, closes #322), portable Playnite covers
    (#330), plugin-kit republish on startup/manual (#334), console touch
    gestures and idle gates (#333), cancel-connect (#329), NixOS host
    builds failing outright (#328 + #332 + #336).
  * Docs overhaul (#337/#340/#343/#346): get-started track, Switching
    from Sunshine, screenshots, and data/platforms.json as the single
    source for install facts with seven CI drift gates over it.

Gates on this tree: cargo fmt --all --check clean (no drift this time —
the first cut needed a whitespace commit, which is already in main);
cargo metadata --offline ok; cargo test -p punktfunk-core --lib 273
passed; Play notes gate verbatim 456/500 and unique; voice scan clean
outside For developers; both openapi copies cmp identical at 0.31.0.

⚠ The C ABI harness still did not run here — no libopus on this MacBook
— and this time the header DID change, so ABI 25's C compile is owed to
CI. Named in Verification status rather than left to be found.

⚠ Owed outside this repo: platforms.json changed (the Windows download),
so punktfunk-website needs `bun run sync-platforms` committed — the step
#346 added to docs/releases/README.md, and this is its first exercise.
2026-08-20 01:24:15 +02:00
enricobuehlerandClaude Fable 5 c23fc84bef docs(guides): trim round 2 — each page sheds one prose category, no fact rows, headings or commands touched
The category sacrificed, per page (RFC 'screenshots over prose' / phase-2 handoff item 3):
- client-settings.md (4368→3144 words): the why-rationale narrative around each setting; every
  default, number, availability list, env var and link stays.
- virtual-displays.md (3724→3015): status/history notes (the 'What's live today' tracker
  paragraph, 'Windows has always lingered 10 s') and design rationale; compositor-specific
  operational facts stay.
- plugins.mdx (2835→2418): narrative duplication — restart instructions stated twice, story
  asides the linked plugin repos carry.
- input.md (2499→2403) and wake-on-lan.md (2286→2186): rationale asides and historical notes
  ('the plugin used to fire a packet itself…'); these two are per-platform fact tables end to
  end, so only a light pass was honest.

15712→13166 words over the five biggest Guides (−16%). Zero heading changes (anchors stable),
docs-drift + docs-links green, docs-site build + lint green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:17:16 +02:00
enricobuehler 0e5a059098 fix(nix): pin gamescope's src to 5fb8dce4, like every other channel
nix was the ONLY channel not pinning the compositor — the RPM spec, the
PKGBUILD, build-punktfunk-gamescope.sh and packaging/gamescope/README.md all
ship 5fb8dce4 (3.16.25-11), while gamescope.nix patched whatever version
nixpkgs happened to carry. That produced two failures in two days, both the
same bug:

  * nixpkgs shipped 3.16.24, where patch 0009's context does not exist, so the
    build died at patchPhase. host.gamescopeHdr defaults true, so every
    `services.punktfunk.host.enable = true` failed the same way.
  * bumping the lock to 3.16.25 fixed the patches, and then --version printed
    NOTHING. Upstream's `gamescope::PrintVersion()` landed AFTER the 3.16.25
    tag; in that tag `--version` is `return 0;` with the comment "We always
    print the version to stderr anyway". The host reads that banner to decide a
    session's bit depth and cursor compositing BEFORE the virtual display
    exists, so a silent banner is a silent fall back to SDR.

The installCheck was right to fail — this was not a check problem.

Also in this commit: the +pfhdr guard now prints the banner it read. That is
how the empty output was identified at all; without it the failure is
indistinguishable from the binary not starting, at ~15 min per guess.

vcs_tag now substitutes the PINNED version (old.version is the pre-override
attr and would still say 3.16.25).

nix-instantiate --parse clean. Hash from nix-prefetch-git --fetch-submodules.
2026-08-19 23:16:18 +00:00
enricobuehlerandClaude Fable 5 674b16d8eb linux-client-screenshots: publish the PNGs to the generic package registry too — the v3 artifact is browser-only, which blocked reusing the shots for the docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:08:31 +02:00
enricobuehler d801cb72f2 Merge pull request 'Windows client: per-user installer + portable zip as the default download — a stable exe path Steam can launch (overlay, Big Picture); MSIX stays for the Store' (#349) from worktree-win-client-installer into main 2026-08-19 23:05:32 +00:00
enricobuehler b03acc9153 Windows client: ship a per-user Inno Setup installer + portable zip as the default download; keep the MSIX for the Store
A user report: launching via Big Picture doesn't work and the Steam overlay never appears.
Not because the app is UWP (it's full-trust Win32 under MSIX too) but because of the MSIX
install SHAPE: the exe lives under the ACL'd WindowsApps dir Steam's non-Steam-game picker
can't browse, and alias/shell:AppsFolder activation defeats the overlay's injection — Steam
must spawn the exe itself from a normal path.

- punktfunk-client.iss: per-user (no UAC) install to %LOCALAPPDATA%\Programs\Punktfunk.
  Re-creates the manifest's grants in HKCU (punktfunk:// scheme, Start entries, {app} on the
  user PATH for the punktfunk CLI) and fetches the Windows App Runtime when missing.
- pack-client-installer.ps1: consumes pack-msix.ps1's layout (one assembly, three artifacts),
  signs the four exes individually, emits setup.exe + a portable zip. Same signing backends
  and fail-closed-on-tags rule as the sibling scripts; no .cer (an exe runs untrusted).
- windows-client.yml: pack step after the MSIX, publish + release-attach the new artifacts
  (canary/latest aliases punktfunk-client-setup_<arch>.exe, ..._<arch>-portable.zip).
- deeplink.rs: write_shortcut targets the app-execution alias only under package identity;
  unpackaged installs have no alias but a stable path, so they target current_exe().
  has_package_identity() now shared with main.rs's AppUserModelID probe.
- docs: install-client (installer default + a 'Launching through Steam' section + MSIX/portable
  as alternates), channels, uninstall, clients, platforms.json (both copies, drift check green).
2026-08-20 00:52:46 +02:00
enricobuehler ace01f06a2 Merge pull request 'Cut SDK 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships' (#348) from release-sdk-0.1.5 into main 2026-08-19 22:51:11 +00:00
enricobuehler e4ec4cec31 chore(sdk): cut 0.1.5 — the mgmt-endpoint fix cannot reach a plugin until it ships
v0.31.0's changelog recorded this cut as owed and said why: plugins resolve
`@punktfunk/host` from the registry, so a fix sitting in `sdk/` reaches nobody
until a version carries it. Four commits have touched the SDK since sdk-v0.1.4,
the headline one being the mgmt-port fix — a moved `PUNKTFUNK_MGMT_BIND` left
every plugin and the tray dialing 47990.

`SDK_VERSION` moves with it. It is a hand-maintained constant (the bundled
runner has no package.json to read at runtime), and the runner compares it
against the SDK actually installed in the plugins tree to decide whether to
reinstall — so shipping 0.1.5 with the constant still reading 0.1.4 would have
published the fix and then never delivered it. `version.test.ts` exists for
exactly this and caught it.

Gates: 83/83 SDK tests pass.
2026-08-20 00:50:46 +02:00
enricobuehler 245173a731 fix(nix): make the +pfhdr guard show the banner it actually read
The memory raise got gamescope building, and it now reaches installCheckPhase
and fails there:

    punktfunk-gamescope: the +pfhdr marker is missing — the patches did not take

Patch 0005 applied cleanly to src/meson.build in that same run, so the message
is misleading: the patch DID take. Two very different causes are
indistinguishable from the log as written —

  * the binary never ran (shrunk RPATH, missing loader dep), so --version
    printed nothing at all; or
  * it ran and printed a version without +pfhdrN, meaning upstream no longer
    builds the banner from VCS_TAG.

A guard that reports 'missing' without showing what it read cannot be acted on,
and each guess costs a full compositor build. Capture the output and print it on
failure, with a note on how to read the two cases apart.

No behaviour change: same assertion, same exit. Also verified in this run: the
chmod fix works (prune, rename and layer install all completed) and the WSI
layer is installed.
2026-08-19 22:46:07 +00:00
enricobuehler 230d253b06 Merge pull request 'The Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all' (#347) from worktree-deck-wol-learn-mac into main
Reviewed-on: unom/punktfunk#347
2026-08-19 22:41:32 +00:00
enricobuehler def215ae8e Merge pull request 'Installer bedding-in (--uninstall, NVIDIA silent-failure checks, detection matrix as gate 7) and screenshots for the get-started track' (#346) from worktree-docs-phase2 into main 2026-08-19 22:40:55 +00:00
enricobuehlerandClaude Fable 5 dfcc530ee7 installer bedding-in: --uninstall, NVIDIA silent-failure checks, the detection matrix as CI gate 7 — and the get-started track gets its screenshots
Phase 2 of the docs-and-onboarding overhaul (items 1-partial, 2 and 4 of the handoff):

install.sh: --uninstall reverses step 1 + step 6 per family (user units off first, only the
punktfunk packages actually installed, then the repo; config/groups/firewall stay, as
/docs/uninstall states) — smoke-tested as a new installer-smoke step on all three families.
The end-of-run check now catches the two NVIDIA silent failures on every family: no driver at
all, and a module the kernel refused to load (Secure Boot) via an nvidia-smi probe pointing at
the troubleshooting anchor; the Fedora ffmpeg-libs/NVENC warning folds into the same block.

check-docs-drift.sh gate 7: the manual 16-file os-release matrix PR #345 was verified with,
committed — every family's detection, its install line, its removal line and the four
unsupported pointers run through the real script under --dry-run on every push (docs-drift's
container gains curl, the script's own prerequisite).

Screenshots (RFC: "screenshots over prose"): four console shots captured from the same
storybook-fixture pipeline web-screenshots.yml runs — login and the armed Pairing page into
quickstart.md, the Approve dialog (access level + expiry + guest fast-path) into pairing.md
replacing the prose that described it, live status into web-console.md. Files under
docs-site/public/img/, dark-theme, bundled+preloaded by the docs build (verified served).
Still missing: a client host-list shot — linux-client-screenshots run 19546 built it, but its
artifact isn't API-downloadable; add it when a browser session can fetch the zip.

WP5 rider: the release-flow docs-freshness step now includes the website content look-over.

NOT flipped: installer stays preview — the handoff gates the default flip on real-box mileage
(Bazzite above all), which a Mac can't provide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:56:19 +02:00
enricobuehler 6b5307618f Merge pull request 'KWin creates our virtual output disabled and refuses to stream it — enable it and retry' (#344) from worktree-kwin-vout-enable-repair into main 2026-08-19 20:48:43 +00:00
enricobuehler cdacd5636e Merge remote-tracking branch 'origin/main' into worktree-kwin-vout-enable-repair 2026-08-19 22:39:11 +02:00
enricobuehler 47f01149bf Merge pull request 'Guided Linux installer (preview): one command from nothing to a pairable host, running exactly what platforms.json states, smoke-tested in CI per package family' (#345) from worktree-wp4-install-script into main 2026-08-19 19:01:45 +00:00
enricobuehlerandClaude Fable 5 59ef285f08 process: the PR template asks whether a user-facing fact changed, and the release flow gets a docs-freshness step
WP5 of the docs-and-onboarding overhaul, the two riders the RFC attaches to WP2–WP4:

- .gitea/PULL_REQUEST_TEMPLATE.md — one question: did a user-facing fact change, and is the
  docs-site page that owns it updated in this PR (install/repo/port facts in data/platforms.json).
  CI's docs-drift only catches the mechanical half; this is the reminder for the rest.
- docs/releases/README.md step 1 — while the release diff is in front of you, check docs freshness,
  and if platforms.json changed, run `bun run sync-platforms` in punktfunk-website and commit,
  because the download page vendors that file and only refreshes when someone does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:01:00 +02:00
enricobuehler 5bec450402 install.sh: probe /dev/tty by opening it — a container has the node but no controlling terminal, so -r/-w said yes and the redirect failed (first installer-smoke run); name the matrix jobs by family 2026-08-19 20:57:02 +02:00
enricobuehlerandClaude Fable 5 cd6ce34892 install.sh: a guided Linux installer (preview) that runs exactly the commands platforms.json states, with a CI smoke test per package family
WP4 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md).

scripts/install.sh — plain POSIX sh (dash-clean), ~380 lines, `curl -fsSL https://punktfunk.unom.io/install.sh | sh`:
detect the distro from os-release (apt / dnf / pacman / rpm-ostree→sysext; NixOS, SteamOS, Windows
and unknown distros get a one-line pointer and stop; Debian 12 / Ubuntu 24.04 / Mint 22 / Fedora
45 hit the documented floors with the right docs link) → install with the platforms.json lines
VERBATIM (channel and the Fedora group are edited into the string at run time; `--yes` rewrites
them non-interactive, a tty hands the package manager its own prompt; stdin is never read, because
under `curl | sh` stdin is the script) → `punktfunk-host detect-conflicts` (exit 1 = active
Sunshine-family host) → offer to keep both by moving the management API port (PUNKTFUNK_MGMT_BIND,
default 47991, the firewall step opens it) → input group (ujust on Bazzite; no-op if already in) →
optional punktfunk group, GameStream compat, shared clipboard (all default no) → firewalld/ufw
profiles → enable host + console (+ the plugin runner where it isn't) → optional linger → verify
(unit active, UDP 9777 bound) and print the console URL, the password command and the pairing
steps. `--dry-run` prints every command and changes nothing; every prompt has a PUNKTFUNK_INSTALL_*
environment twin; re-running is safe (install skipped when the binary exists). Running under sudo
is refused (host.env and the units belong to the user); root without sudo gets a shim so the
verbatim lines still work.

Decisions: the canonical URL is punktfunk.unom.io/install.sh, a 302 on the website to the script at
raw/branch/main (versioned with the code it installs; precedent: the Bazzite sysext bootstrap) —
the website half is punktfunk-website PR #4. GPU drivers stay the docs pages' job; the one silent
failure (Fedora + NVIDIA without RPM Fusion's ffmpeg-libs → no NVENC) is called out at the end.

Gates: check-docs-drift.sh gate 6 — every apt/pacman/dnf/sysext install line in data/platforms.json
must appear verbatim in the script, and the script must parse (shown to fail on a planted drift).
New path-filtered workflow installer-smoke.yml runs the script unattended in debian:trixie,
fedora:44 and archlinux:base against the real registry, then `punktfunk-host --version`,
`detect-conflicts`, and a re-run that must say "already installed".

Docs: install hub gains "Guided install (preview)" rendered from platforms.json's new `installer`
block via an <Installer/> component (one-liner + inspect-first form + flags); CONTRIBUTING names
the new gate. Verified locally: sh/dash -n, both docs gates, docs-site build + lint, and a
--dry-run matrix over 16 faked os-release files (all four families, every floor, canary, every
option, piped stdin). The container run itself is the CI job's to report — Docker on this machine
was wedged under another session's emulated build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:54:13 +02:00
enricobuehler 20568d988f fix(kwin): KWin creates our virtual output disabled and refuses to stream it — enable it and retry
On KWin >= 6.6 `streamVirtualOutput` creates the output on the backend and then
passes `workspace()->findOutput(output)` to the stream, which is null for an
output the workspace does not manage (`wantsToManage` = `isEnabled() &&
!isNonDesktop()`). So an output KWin creates DISABLED is refused with
"Could not find output" — translated into the session's language, and logged
nowhere, because disabling an output is a perfectly valid configuration that
applies successfully. 6.4/6.5 passed the backend output straight through and
streamed it either way.

It repeats forever. The host asks for a STABLE per-client output name precisely
so KWin persists that client's scale and mode against it, so a stored setup
naming it `enabled: false` is reapplied to every future session for that client
— and the user cannot fix it in System Settings, because the output only exists
for the few milliseconds the request is alive.

Repair it instead. On a refusal, enable the head over kde_output_management_v2
and let the retry go again. Two properties of KWin make that possible, both read
off Plasma/6.7 rather than assumed:

  * `sendFailed` only sends the event — it does not emit `finished`, and
    `removeVirtualOutput` is wired to `finished`. The disabled output therefore
    stays alive for as long as we hold the failed stream open, which is the
    window the repair runs in (and why it must run inside the worker thread,
    before the connection drops).
  * `WaylandServer::handleOutputAdded` offers EVERY backend output to the
    output-device registry, gating only placeholders and non-desktop ones. Only
    `wl_output` is gated on being enabled, so a disabled output is invisible to
    `stream_output` but fully addressable over output management.

Enabling it is a user-applied configuration, so KWin persists it against that
output's identity: the retry's fresh request finds a stored setup that enables
it. The repair therefore fixes attempt N+1, never attempt N.

`REPAIRED_HINT` keeps that retry reachable. The opener wraps every worker error
with "KWin virtual output failed", which is the phrase the host's
`is_permanent_build_error` matches to short-circuit the retry loop — so a
repaired refusal carrying it would be classified permanent and the retry that
consumes the repair would never run, making the whole path dead code. A repaired
refusal is reported verbatim without that wrapper; an unrepairable one keeps it
and still fails fast, because nothing about the box changed. The host-side test
pins both halves.
2026-08-19 20:29:27 +02:00
enricobuehler 2a60f94f74 Merge pull request 'Land the WP2 docs rewrite on main (#340 merged into the already-merged #337 branch) and make docs-drift green again' (#343) from docs-wp2-to-main into main 2026-08-19 18:23:48 +00:00
enricobuehler 60406c9d72 Merge origin/main into the docs WP2 branch — bazzite.md was rewritten as bazzite.mdx here and re-touched by #342 there; #342's fact (the Gaming Mode takeover no longer needs the punktfunk group) is ported into bazzite.mdx and the four distro pages, and the Apple client's PUNKTFUNK_CORE_LOG_LEVEL (from #338) is baselined so docs-drift is green on main again. 2026-08-19 20:21:44 +02:00
enricobuehler 65f697651e Merge pull request 'The bun OOM is a 7 GiB cgroup cap, not a big build — measure it in the log' (#341) from nix-serialise-bun-builds into main
Reviewed-on: unom/punktfunk#341
2026-08-19 18:18:28 +00:00
enricobuehler a00c4d2a6a Merge pull request 'Docs for two audiences: a get-started track that fits on one screen, a Switching-from-Sunshine page, and install commands quoted from platforms.json' (#340) from worktree-docs-wp2-rewrite into worktree-docs-wp0-wp1-anti-drift
Reviewed-on: unom/punktfunk#340
2026-08-19 18:16:28 +00:00
enricobuehler e12ef6633c Merge pull request 'A stopped display manager left the box unable to switch sessions — idle its autologin instead' (#342) from worktree-kwin-vout-failed-permanent into main 2026-08-19 17:40:07 +00:00
enricobuehler 38a0f54b09 refactor(gamescope): delete the display-manager stop chain the idled takeover retired
`cargo check` on Linux flagged six functions the previous commit orphaned. The
linger machinery existed for one reason — stopping the display manager ends the
user's last login session, and logind would take the host down with it about 10 s
later — and nothing stops a display manager any more, so the whole chain goes:
`try_stop_display_manager`, `ensure_host_survives_dm_stop`,
`host_is_under_user_manager`, `cgroup_under_user_manager`, `linger_enabled`, and
the one test that covered only the cgroup predicate. 142 lines out, 17 in.

`mask_unit` becomes `#[cfg(test)]` rather than going with them. Its only caller
now is the mask-lift test, and that test still guards live code:
`lift_autologin_mask` cleans up a takeover adopted from a host old enough to have
laid a mask, so the state it undoes has to stay constructible.

`pf-dm-helper` keeps its `stop` and `linger` verbs even though no caller is left —
only `restore` is still reached — because removing them changes the shipped polkit
action's surface, which is a packaging decision rather than a cleanup.
2026-08-19 19:38:57 +02:00
enricobuehler c420ae4676 Merge pull request 'The Apple client can send its logs to the host — and finally keeps a log worth sending' (#338) from worktree-apple-send-logs into main 2026-08-19 17:35:44 +00:00
enricobuehlerandClaude Fable 5 bfd0de8973 docs: two audiences, one home per fact — get-started rewrite, a Switching-from-Sunshine page, and install commands quoted from platforms.json
WP2 of the docs-and-onboarding overhaul (punktfunk-planning design/docs-and-onboarding-overhaul.md),
stacked on #337 (WP0+WP1).

Nav (meta.json) is now Get started / Guides / Switching from Sunshine / Reference / Troubleshooting.

Get-started track, rewritten for someone with no Linux expertise — one task per page, happy path,
under two minutes each: quickstart (5 steps), install (a pick-your-system hub), ubuntu, debian,
fedora, arch, bazzite (+ a firewall step it was missing), a new nixos page (moved out of install),
pairing. Every distro page quotes its install snippet through a new <Install platform="…"/> MDX
component that reads docs-site/src/data/platforms.json — a byte-identical snapshot of
data/platforms.json (the Docker build context is docs-site/ alone, same arrangement as
openapi.json) which scripts/ci/check-docs-drift.sh now gates. <Ports/> renders the port table the
same way on a new reference page, ports.mdx, so the four distro pages stop restating port lists.

platforms.json itself was fixed while wiring it up: the apt snippet lacked the keyring lines, the
winget one lacked `winget source add`, the Arch repo-add is now idempotent (grep guard — the
documented double-append gotcha disappears at the source), names match the real floors (Ubuntu
26.04+, Windows 11 22H2+), a web-console port entry and the firewall profile names were added, and
client platforms (Windows MSIX, macOS, TestFlight, Play, Steam Deck) so the website can render its
cards from the same file.

Dense material moved down rather than deleted: build-from-source.md (the three distro appendices),
the Mint/LMDE/Cinnamon analysis → requirements.md, TOFU/`--open` → security.md, Secure Boot MOK
paths and the Arch libavcodec soname refusal → troubleshooting, sysext channels/rollback/rebase →
updating.md, the 0.28.1 cert note → windows-host.

New page switching-from-sunshine.md: coexistence via the 47990 move (PUNKTFUNK_MGMT_BIND), the
Windows display-topology overlap, detect-conflicts exit semantics (1 only for an active host — the
troubleshooting page said "non-zero if any"), what maps to what, migration steps. The
troubleshooting Sunshine section is tightened and points there.

The three facts whose only home was a README now live in troubleshooting — the ffmpeg-libs weak
dependency (no NVENC on Fedora), the pacman double-append error, the ds_inhibit SELinux storm with
DualSense pads on Bazzite — and packaging/bazzite + packaging/arch READMEs are pointers.

Stale claims corrected against the tree: the packaged unit runs the native-only `serve` and
GameStream is opt-in on every route (kde, hyprland, security, steamos-host said otherwise);
host.env is optional (EnvironmentFile=-); Fedora 43 uses the `bazzite` group; the iOS clipboard
bridge exists; PUNKTFUNK_GAMESCOPE_SDR_NITS defaults to 203.

Guides trimmed for verbosity (~5% by words — they are fact-dense; every heading, command, number,
env var and link target is preserved, verified by token-inventory diffs). Tooling: docs-site README
and CONTRIBUTING describe the platforms snapshot; input/uninstall/support-matrix links retargeted.

Verified: check-docs-drift.sh and check-docs-links.sh green, docs-site `bun run build` + `bun run
lint` green, and the built site served locally to confirm every cross-page anchor the rewrite
links to (37) renders, plus the <Install/> blocks and the <Ports/> table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:08:38 +02:00
enricobuehlerandClaude Fable 5 1dd5df0127 ci: the drift gate matched its own comment, and the OpenAPI snapshot was two versions stale
Two reds on #337's first CI run, both self-inflicted:

- Gate 3 greps every quoted "PUNKTFUNK_*" literal outside docs-site — including the gate's own
  explanatory comment, which spelled out ("PUNKTFUNK_X") as an example and so reported PUNKTFUNK_X
  as a new undocumented knob. Reworded the comment; the regex is unchanged.
- The `rust` job's regenerate-and-diff found `api/openapi.json` still saying `"version": "0.29.0"`
  while the crate is 0.31.0 — the only difference in the whole spec. Regenerated with
  `cargo run -p punktfunk-host -- openapi` (in the punktfunk-rust-ci image) and copied the
  docs-site snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:07:56 +02:00
enricobuehler 08c45b96eb fix(nix): measure the cgroup cap instead of guessing at the bun OOM
Reverts the --max-jobs 1 from the previous commit. The hypothesis it encoded —
that punktfunk-web and punktfunk-scripting bundling concurrently exhausted the
box — is DISPROVEN: run 19444 failed identically with it, and the Environment
step it added shows why the premise was wrong:

    Mem: 125Gi total, 48Gi available     Swap: 8.0Gi     nproc: 16
    overlay 172G, 108G avail

Nothing was scarce. Serialising also would have cost real wall-clock on the
publish tier, which builds six derivations including an hour of Rust, so it does
not stay on an unproven basis.

Also ruled out: bun is 1.3.13 in BOTH the old and new nixpkgs, so the lock bump
did not change the bundler.

What remains is what those numbers cannot see. Inside a container /proc/meminfo
and free(1) report the HOST, so a cgroup memory cap is invisible to them — and a
cap is the only explanation left for a SIGKILL with 48Gi apparently free. So
read it directly: cgroup v2 memory.max, falling back to v1
memory.limit_in_bytes.

No fix in this commit, deliberately. Two guesses have already cost a day of red
runs; the next change should follow the number.
2026-08-19 17:06:24 +00:00
enricobuehler a52d60e242 test(core): the log-sink collector asserts nothing itself — an extern "C" fn must not panic (hygiene gate B) 2026-08-19 19:02:21 +02:00
enricobuehler c2f5e91b3d fix(gamescope): a stopped display manager left the box unable to switch sessions — idle its autologin instead
The managed takeover freed Steam by stopping the box's gaming session and,
because a display-manager autologin puts that session straight back, by stopping
the display manager too. That worked until the user asked for a desktop session:
with no DM there is nothing on the box able to start one, so Steam's own "Switch
to Desktop" sat on its modal until a reboot (field report 2026-08-18, .41).

It also could not be detected and worked around. On a steamos-manager box the
switch is a D-Bus call whose every trace is written by the display manager we had
just stopped — measured on .41: the `~/.config/steamos-session-select` sentinel is
never written (that is the ChimeraOS/Nobara layout), `/var/lib/sddm/state.conf`
only advances when sddm actually STARTS a session, `get-default-login-mode` stays
`game` for a non-persistent switch, and `graphical-session.target` going inactive
fires at takeover time too. There is nothing to watch, because the component that
would produce the signal is the one we disabled.

So stop disabling it. The takeover now idles the box's autologin session for the
stream's duration — a drop-in over the `gamescope-session-plus@` template that
replaces `ExecStart` with a process that sleeps — and leaves the DM running. The
autologin still SUCCEEDS, so there is no failed unit to relogin against (a masked
one fails in milliseconds, which is the storm's engine); the session runs nothing,
so Steam is free; and the DM is alive, so the box can service the switch itself.
No privilege, no DM-flavor matrix, and no detection.

Measured on .41, both directions: takeover leaves `steam` down, `sddm` active and
the unit `active (running)` with NRestarts=0; the switch that used to hang brings
Plasma up in ~10 s; the restore puts Steam back within 5 s.

The drop-in lives under $XDG_RUNTIME_DIR for the same reason the bind one does —
it replaces the box's game-mode ExecStart, so a copy outliving the host would be a
box whose Game Mode silently does nothing — and is swept unconditionally at
startup. Its removal sits above every early return in the restore, next to the
bind drop-in's, because the desktop-active return is exactly the path that would
leak it. The restore restarts rather than starts: the unit is active-but-idle, and
`start` on an active unit is a no-op that would log success over it.

Docs and the shipped Bazzite host.env said the takeover has to stop the display
manager and needs the `punktfunk` group; both are now false. The group still gates
the usbip nodes the virtual Steam Deck pad attaches through, which is what the
advice narrows to.
2026-08-19 18:58:18 +02:00
enricobuehler 44cb7f7815 test(core): keep the SAFETY comment on the line before its unsafe block (rustfmt had split them) 2026-08-19 18:53:20 +02:00
enricobuehler 52b89a1592 style(core): rustfmt the log-sink additions 2026-08-19 18:47:47 +02:00
enricobuehler 57446f9ed9 feat(core): ABI v25 — punktfunk_set_log_callback, so the Apple client hears the core's log lines
The core logs through tracing; the desktop and Android shells install a subscriber/logger and
see those lines, the Swift client installed none and saw nothing — every transport warning,
quinn connection event and rustls handshake note vanished, and a client log bundle sent to the
host carried the shell's half only.

- punktfunk_set_log_callback(max_level, cb, user): a log::Log backend behind a C callback
  (level, target, message, user), level-gated by log::set_max_level so anything above the
  ceiling costs no formatting; NULL detaches; Unsupported when another log backend already owns
  the process (android_logger). tracing's `log` feature declared explicitly by the core — it was
  on transitively via quinn, which an ABI promise must not rest on. ABI_VERSION 24 → 25, header
  regenerated, embedding doc §2.6.
- Apple: CoreLog.install() at app start routes the lines into ClientLog under core.<crate>
  (os_log + the send-to-host ring), info ceiling by default (PUNKTFUNK_CORE_LOG_LEVEL raises it).
- Test: a log record and a tracing event both reach the callback with level/target/message,
  interior NUL dropped not truncated, ceiling honoured, detach silences.
2026-08-19 18:33:59 +02:00
enricobuehler a4f6e259e3 fix(nix): serialise the bun builds — punktfunk-web is OOM-killed beside scripting
The nix job's recurring red is not a broken bundle, it is memory:

    punktfunk-web> error: script "build" was terminated by signal SIGKILL (Forced quit)
    Reason: builder failed with exit code 137

MEASURED 2026-08-19: five runs across main and two branches died exactly there,
while other runs on the SAME commits passed — the signature of pressure, not of
a defect. nix builds punktfunk-web and punktfunk-scripting concurrently, and
each one runs a memory-hungry bundler; that pairing is what the runner cannot
afford.

`--max-jobs 1` caps how many DERIVATIONS build at once, not the cores inside
one, so two bundles that each pin a core simply run in sequence. Applied to the
publish tier too, which builds six derivations including those same two.

This step sits BEFORE the publish tier, so every one of those failures also
stopped the binary cache from filling — it has blocked more runs today than
every real bug combined.

Also prints memory in the Environment step. An exit 137 cannot be explained by
the disk numbers that step used to gather on their own.
2026-08-19 16:30:41 +00:00
enricobuehler 6d82716598 Merge pull request 'Android "Send logs to host": the logcat-teed ring uploads over the client's own mTLS' (#339) from worktree-console-ui-sendlogs-android into main
Reviewed-on: unom/punktfunk#339
2026-08-19 16:28:27 +00:00
enricobuehler f584eebb92 feat(android): "Send logs to host" works from the console — the ring, teed from logcat, uploads over the client's own mTLS
WP5b of punktfunk-planning design/console-ui-sweep-2026-08-19.md (the last open item):

- pf-client-core: the logring's RING half (note/render/wallclock — std only) is
  Android-enabled; `send_to_host` stays desktop-gated with the rest of the ureq
  fetches. `wallclock` moves in from the session's ring_layer so every ring feeder
  stamps lines the same way.
- Android native: JNI_OnLoad installs a RingTee — every `log` record goes to logcat
  AND into the ring, in the desktop ring_layer's line shape. `nativeRenderLogs(header)`
  hands Kotlin the rendered bundle.
- Kotlin: `SkiaConsole.sendLogs` replaces the not-available stub — renders the ring
  and POSTs it to /api/v1/client-logs over `mtlsHttpClient` (the library/art path),
  noticing the desktop wording on success/failure. The upload deliberately stays on
  the Kotlin side: OkHttp already owns HTTPS-to-the-pinned-host on this platform,
  and pulling ureq+rustls into the .so for one POST would be a dependency change,
  not a feature.
- console-ui: the host menu's "Send logs" desktop-only gate is gone — paired and
  reachable is the whole condition again; the pinning test flips to assert both
  platforms offer it.
2026-08-19 18:26:13 +02:00
enricobuehler cd0a370229 Merge pull request 'One home per fact: IA rules, docs drift gates in CI, and the READMEs stop restating the docs' (#337) from worktree-docs-wp0-wp1-anti-drift into main
Reviewed-on: unom/punktfunk#337
2026-08-19 16:22:04 +00:00
enricobuehler 3301f5aa60 feat(clients/apple): Send logs to host — a bounded in-app log ring, posted to the paired host from the card menu and the gamepad host options
The Gaming Mode console got "Send logs to host" in 0.30 (pf_client_core::logring +
POST /api/v1/client-logs); the Apple client had no ring to send and no POST on its mgmt
transport, and its own session lifecycle was never logged at all — a field report from an
Apple TV carried only the host's half.

- ClientLog: drop-in for Logger(subsystem: "io.unom.punktfunk", category:) — same call
  shape (privacy:/format: interpolations accepted), writes os_log AND a process-global ring
  bounded by 4096 lines / 768 KiB (under the host's 1 MiB cap), stamped with wall-clock
  ISO-8601 so a bundle lines up with the host log. .debug stays out of the ring (per-key
  input chatter would flush it — the Steam Deck DPB lesson). 13 Logger declarations swapped.
- MgmtTransport/MgmtConnection: POST with a length-framed body on the same pooled, pinned
  mTLS connection; LibraryClient.sendLogs posts the ring and returns the bundle id.
- SendLogs.toHost (app): identity + pinned fingerprint required, same gates as the library.
- UI: "Send Logs to Host" in the host card's context menu (paired hosts) with an alert for
  the outcome; a "Send logs to host" row on the gamepad host options screen whose label and
  detail band report Sending…/Logs sent/Couldn't send in place, like Copy link.
- SessionModel logs connect asked/landed/refused, disconnect and session-ended reason.
- ClientLogTests: ring bounds + eviction note + truncation, logger format, POST framing.
2026-08-19 18:17:59 +02:00
enricobuehler 1f0b12de6d Merge pull request 'The gamescope prune can't delete reshade — it installs under read-only directories' (#336) from gamescope-prune-writable into main
Reviewed-on: unom/punktfunk#336
2026-08-19 14:42:29 +00:00
enricobuehler 3def50a88a Merge pull request '"Capture system shortcuts" on Mac reaches ⌘Space and ⌘Tab, with Accessibility' (#335) from worktree-apple-system-shortcut-tap into main
Reviewed-on: unom/punktfunk#335
2026-08-19 14:40:15 +00:00
enricobuehlerandClaude Opus 5 8f9e451395 docs: one home per fact — IA rules, CI drift gates, and READMEs stop restating the docs
WP0+WP1 of the docs-and-onboarding overhaul (punktfunk-planning
design/docs-and-onboarding-overhaul.md):

- CONTRIBUTING.md + docs-site/README.md state the ownership rule (docs-site
  owns user-facing facts; READMEs keep rationale + pointers; the website
  deep-links) and the get-started vs reference audience split.
- ci.yml gains a docs-drift job (scripts/ci/check-docs-drift.sh +
  check-docs-links.sh): the OpenAPI snapshot must be a byte copy of
  api/openapi.json, PUNKTFUNK_* vars the docs mention must exist in the tree,
  the undocumented-var set is ratcheted against a named baseline file,
  host-cli.md commands must exist in the host crate, data/platforms.json must
  parse, and internal doc/README links must resolve. The rust job additionally
  regenerates the spec from the built host and diffs it — closing the 'not
  gated' hole CONTRIBUTING itself confessed to. All seven gates were shown to
  FAIL on planted violations before landing (the gate-of-the-gate rule).
- data/platforms.json: single source for install commands, repo URLs, ports
  and the Sunshine/Apollo/Vibeshine conflict facts — the docs rewrite (WP2),
  website download page (WP3) and guided installer (WP4) consume it.
- READMEs (root, bazzite, debian, arch, rpm, flatpak) stop restating docs
  walkthroughs (net -444 lines); facts whose only home a README was (the
  ds_inhibit SELinux storm, the ffmpeg-libs weak dep, the pacman.conf
  double-append) stay put, marked for the WP2 migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:39:39 +02:00
enricobuehler 0d22333831 Merge pull request 'Console-UI sweep: touch gestures, controller-audio rows, host-menu bind/clipboard, Android idle gates' (#333) from worktree-console-ui-sweep-wps into main
Reviewed-on: unom/punktfunk#333
2026-08-19 14:26:32 +00:00
enricobuehler 47602f7e59 feat(apple): "Capture system shortcuts" reaches ⌘Space and ⌘Tab, with Accessibility
A 0.30.0 user reported the setting does nothing for ⌘Space. It never could:
the macOS implementation (0.29, b2146f33) is an NSEvent local monitor, which
only sees the keys AppKit delivers to the app — ⌘Q, ⌘W and their like. The
shortcuts macOS itself owns (⌘Space → Spotlight, ⌘Tab → the Dock, ⌃↑ →
Mission Control, everything under System Settings › Keyboard › Shortcuts) are
consumed by WindowServer before any app is asked. The SDL clients take those
via the private CGSSetGlobalHotKeyOperatingMode, which SDL only compiles in
outside the sandbox; this app is sandboxed on both channels.

The sandbox-legal way is a session-level CGEventTap, which needs Accessibility.
`InputCapture` now installs one while forwarding (and only then — it comes
down with setForwarding(false)/stop(), and capture already releases on any
focus loss, so the tap is never live with another app frontmost). The tap
forwards nothing itself: it takes each keyDown/keyUp off the system and
re-posts it into this app's own queue, addressed to the key window, so it
arrives exactly where the same key would have had macOS not claimed it — the
monitor first (client chords, ⌘ chords → host), then StreamLayerView (the
rest). One key path, no second VK table, no second release bookkeeping; and
the ⌘-chord keyUps macOS used to swallow now arrive too.

Two things verified in standalone harnesses rather than assumed: a reposted
event does reach a local monitor, and a windowless NSEvent(cgEvent:) does NOT
reach the first responder — NSApp.sendEvent routes key events by
event.window — hence the re-stamp onto the key window's number. The
intercept half (tap ahead of Spotlight, inside the sandbox) needs a granted
Accessibility switch this machine doesn't have; that is the live test left.

Gating per event: forwarding, capture mouse model (⌃⌥⇧M flips it
mid-capture, so it is read live rather than at install), app active. Any
other state passes the key through untouched — a tap that swallows keys for
the whole Mac is the failure mode designed against. Installed on the main
run loop on purpose: a hung main thread trips the tap timeout and macOS hands
the keyboard back; the callback re-arms on kCGEventTapDisabledBy* otherwise.

The Accessibility prompt is asked only from Settings — on a genuine off→on
flip of the toggle, or an explicit "Allow Accessibility access…" button that
also opens the pane — never at stream start, and never for the default-on
users this update lands on. Without the grant the setting keeps doing what it
did in 0.29, and its caption now says exactly which half works. App Review
notes carry the justification.
2026-08-19 15:55:02 +02:00
enricobuehler f415c7d090 Merge pull request 'Cut plugin-kit 0.4.3 — the sync-engine changes plugins cannot pick up otherwise' (#334) from release-plugin-kit-0.4.3 into main 2026-08-19 13:51:03 +00:00
enricobuehler e86c6367e1 chore(plugin-kit): cut 0.4.3 — the sync-engine changes plugins cannot pick up otherwise
Two changes have been sitting in `sync-engine.ts` with no way to reach a
plugin: `minInterval` (the fs-change rate cap), which v0.31.0's changelog
already recorded as owing a cut, and the always-apply sync reasons — `startup`
and `manual` publish even when the fingerprint matches, so an operator who
fixes a host-side art-root problem recovers by restarting the runner instead
of deleting the plugin's cache file.

The registry skips 0.4.2 deliberately. `plugin-kit-v0.4.2` was tagged at the
Steam cover-art commit but its publish never landed — the registry's newest kit
is still 0.4.1 — and the tag is left exactly where it is rather than moved onto
newer code. Every consumer's range is a caret (`^0.4.1`, `^0.4.2`), so 0.4.3
satisfies all of them, and the plugin that asked for `^0.4.2` gets the
cover-art scan it was waiting for in the same package.
2026-08-19 15:47:34 +02:00
enricobuehler 0fd44d8242 fix(console-ui): drop_non_drop in the bind-profile test — NLL already ends the borrow 2026-08-19 15:45:53 +02:00
enricobuehler 6807d7951c fix(nix): make $out writable before the prune — reshade installs read-only
With the WSI layer building, the derivation reaches the prune and dies on every
reshade file it tries to remove:

    rm: cannot remove '.../share/gamescope/reshade/Shaders/lilium__tone_mapping.fx':
        Permission denied

gamescope's own default_extras_install.sh installs those under read-only
DIRECTORIES (mode 555), and rm needs write permission on the CONTAINING
directory rather than on the file. The compositor is fully built and installed
by that point, so the log reads as finished right up to the failure.

Only reachable now that #332 got the build past the layer assertion — the third
latent fault in this install path, each one having masked the next: patch drift
(#328), enableWsi defaulting off (#332), and now this.

Nix seals $out read-only after the builder exits, so widening it mid-build costs
nothing and changes nothing in the output.
2026-08-19 13:39:04 +00:00
enricobuehler f50721aedb fix(console-ui): the two HostRow literals the field sweep missed (pair test, settings test) 2026-08-19 15:32:37 +02:00
enricobuehler 5d3301ed9b feat(console-ui): touch gestures, controller-audio rows, host-menu bind/clipboard, Android idle gates
The 2026-08-19 console-ui sweep (punktfunk-planning design/console-ui-sweep-2026-08-19.md),
WP1-WP6:

- WP1 — touch deferred-tap + drag-to-scroll. PointerInput::Down grows a touch flag
  (SDL Finger* arms and Android toolType feed it; SDL's touch-synthesized mouse events
  are dropped); the shell tracks the gesture: within slop a lift is a tap delivered at
  the anchor, past it drags emit one synthetic scroll tick per 56dp of dominant-axis
  travel. Fixes the on-glass defect where any swipe across the settings list cycled the
  value it landed on (MenuList presses focus AND activate). Mouse behavior unchanged.
  Fling deliberately not included; the Release edge stays for it.
- WP2 — Controller haptics + Controller speaker rows (trust::Settings::{pad_haptics,
  pad_speaker}) in the Controller tab, forwarding-gated like their siblings; the
  speaker row speaks the GTK switch's dialect over the stored string. Detail strings
  are platform-aware now (the Stats/Mouse desktop chords are not taught on Android).
- WP3 — Send logs was already desktop-gated in actions(); pinned by a test.
- WP4 — ConsoleCmd::BindProfile lands (the port design's WP5 leftover): a Default
  profile… host-menu action opens a pin_hosts-style chooser; desktop arm writes
  KnownHost::profile_id, Kotlin arm mirrors it.
- WP5 — per-host Shared clipboard toggle on the host menu (HostRow::clipboard_sync,
  ConsoleCmd::SetClipboard, both service arms). Platform defaults stay divergent on
  purpose; the console shows and writes the stored value only.
- WP6 — Android idle gates: the reachability sweep only probes while the console is
  attached, and the render thread drops to half rate after 60s without input.
2026-08-19 15:14:20 +02:00
enricobuehler bd140bd232 Merge pull request 'gamescope builds no WSI layer, so nothing under it could get an HDR10 swapchain' (#332) from gamescope-enable-wsi-layer into main
Reviewed-on: unom/punktfunk#332
2026-08-19 12:51:39 +00:00
enricobuehler d161c12680 Merge pull request 'A translated KWin refusal burned all 8 retries, and ex11's docs still stated a rule we replaced' (#331) from worktree-kwin-vout-failed-permanent into main
Reviewed-on: unom/punktfunk#331
2026-08-19 12:33:20 +00:00
enricobuehler 7537e8e6b2 Merge pull request 'A portable Playnite's covers were dropped, and nothing republished them once you fixed it' (#330) from worktree-playnite-art-roots-and-blank-sources into main
Reviewed-on: unom/punktfunk#330
2026-08-19 12:29:25 +00:00
enricobuehler 2b81bd286f fix(clients): the Deck learns a host's wake MAC, so Wake-on-LAN can fire there at all
Every wake gate in the codebase reads `!host.mac.is_empty()` against the saved
record — `ConnectPlan::wake`, the console's `can_wake`, `punktfunk wake`. That MAC
only ever reached the store through `trust::learn_mac`, and `learn_mac` had exactly
two callers: the GTK hosts page and the WinUI one.

Neither runs on a Steam Deck. Gaming Mode has only the Decky panel (which drives the
headless CLI) and the console home — and those learned the management port alone,
never the MAC. So a Deck's records stayed MAC-less forever, every wake gate stayed
false, and Wake-on-LAN was skipped silently: no packet, no error, nothing to see.
It worked on desktop purely because those two hosts pages learn on each discovery
tick. (#322)

Rather than add the missing call twice, collapse the three per-field learners
(`learn_mac`, `learn_os`, `learn_mgmt_port` — three `pub fn`s, three load/save
cycles) into one `learn_from_advert`, and call it at every site where an advert
meets a saved record: both desktop hosts pages, the console home, and the CLI's
`discover`. Remembering one call is not a thing a front-end can half-do; remembering
three is what produced this. It takes the three fields rather than a `DiscoveredHost`
because there are two of those — core's and the WinUI shell's verbatim port.

`discover` is where the panel-only flow is fixed: it is the one verb the Decky panel
runs that ever sees an advert. It keeps `KnownHosts::read()`, so it still mints no
ids and cannot join the race that comment warns about, and `learn_from_advert` writes
only when an advert genuinely taught the record something — a steady-state panel
refresh touches no disk.

Two things fall out of the same root cause: the console home now persists the OS
chain too, so a Deck host's icon stops vanishing the moment mDNS goes quiet; and
`punktfunk wake`'s "connect to it once while it's awake" is replaced, since a MAC
comes from an advert and never from a connect — that wording sent this diagnosis
looking in the wrong place.

The magic-packet sender itself was never at fault (`punktfunk-core::wol` passes its
7 tests) and neither was the flatpak sandbox (`--share=network`). Nothing reached
them.

Closes #322
2026-08-19 14:16:14 +02:00
enricobuehler 5711fafa38 fix(nix): build gamescope's WSI layer — nixpkgs defaults enableWsi to false
With the lock bumped, all ten patches apply and the compositor compiles, links
and installs cleanly. It then fails our own postInstall assertion:

    punktfunk-gamescope: this nixpkgs' gamescope built no WSI layer, so no game
                         under the compositor could ever obtain an HDR10 swapchain

nixpkgs takes `enableWsi ? false` and feeds it to
`mesonBool "enable_gamescope_wsi_layer"`, so the plain derivation ships the
compositor and no layer at all; nixpkgs gets its own layer by instantiating a
SECOND copy inside the wrapper. `enableWsi` is a FUNCTION ARGUMENT, so
overrideAttrs cannot reach it — it needs `.override`, applied before
`.overrideAttrs` so the latter is not applied to the derivation being replaced.

The assertion did its job: it caught a compositor that would have installed
under our name and then denied every nested game an HDR10 swapchain, which is
the one outcome the header calls the worst. But it only fires after a full
build (MEASURED: run 19323), which is an expensive way to learn a default.
2026-08-19 11:44:05 +00:00
enricobuehler fa946a16b9 Merge pull request 'Canceling a connect brings the console straight back, instead of sticking on "Canceling…"' (#329) from worktree-console-cancel-connect-stuck into main
Reviewed-on: unom/punktfunk#329
2026-08-19 11:29:22 +00:00
enricobuehler 9a29eb4a7a Merge pull request 'Every NixOS host build was failing: nixpkgs' gamescope 3.16.24 no longer takes our patches' (#328) from nixpkgs-bump-gamescope-3-16-25 into main
Reviewed-on: unom/punktfunk#328
2026-08-19 11:10:40 +00:00
enricobuehler 1b52942bf8 fix(nix): the dispatch opt-ins skipped silently on an API dispatch
`build-rust` / `build-gamescope` were compared only against the STRING 'true'.
A checkbox from the Gitea UI arrives that way, but an API dispatch can deliver a
real JSON boolean, and the comparison then misses — the step is skipped, the job
still reports success, and the log is indistinguishable from a run that had
nothing to do.

MEASURED 2026-08-19: dispatched with build-gamescope=true to verify the flake.lock
bump actually fixes the gamescope patch set. Run 19320 went green with 'Build the
patched gamescope' SKIPPED — a green that proved nothing about the one package
being fixed, on the exact check the header recommends before merging a lock bump.

Accepts both shapes now. Deliberately still no `inputs.*`: that context is what
Gitea's parser is least reliable about, which is why this file used
github.event.inputs in the first place.
2026-08-19 10:56:41 +00:00
enricobuehler 6bec7c7cc6 fix(nix): bump nixpkgs — its gamescope 3.16.24 no longer takes our patches
The publish tier went red on punktfunk-gamescope-3.16.24:

    patching file src/steamcompmgr.cpp
    Hunk #1 FAILED at 9321.
    1 out of 1 hunk FAILED -- saving rejects to src/steamcompmgr.cpp.rej

0009 rewrites the pipewire call site so dead capture textures are reaped on the
compositor thread, and its context is upstream's vblank-driven form:

    // Drive on vblank, not the timer: under VRR the timer starves (page flips re-arm it).
    if ( vblank && pipewire_is_streaming() )

MEASURED against three upstream trees: that context is absent from the 3.16.24
tag and present in both 3.16.25 and master. packaging/gamescope/README.md pins
upstream at 5fb8dce4 (3.16.25-11) and gamescope.nix's header says it was checked
against 3.16.25 — but the lock still carried a 2026-07-15 nixpkgs shipping
3.16.24, five weeks behind. The earlier patches were already only just holding
on: 0004 and 0008 applied at offsets of -51, -48, -35, and one hunk at fuzz 2.

This is not a CI-only failure. gamescope.nix patches whatever gamescope the
pinned nixpkgs carries, and host.gamescopeHdr defaults true, so every
`services.punktfunk.host.enable = true` build was failing the same way. The
cache tier is what surfaced it — exactly what nix.yml's header predicted would
happen, and the reason that tier compiles gamescope on every main push.

Only the nixpkgs node moves (verified: 8 inputs compared, 1 changed).

NOTE: this fixes the instance, not the class. The nix channel is the only one
that does not pin gamescope's src — the RPM spec, the PKGBUILD and
build-punktfunk-gamescope.sh all ship 5fb8dce4 — so the next nixpkgs move can
break it again in either direction.
2026-08-19 10:29:13 +00:00
enricobuehler 8f4e71f8dc fix(console-ui): canceling a connect returns the console immediately, and aborts the dial
The connect takeover could only be dismissed by a session phase coming back from the
embedder, and nothing guaranteed one would. Pressing B latched "Canceling…" and waited:

- Android's console shell never sent a phase at all on the canceled path — `launch()`
  returns early when `Dial.cancelled` is set — so the takeover stayed up until the app
  was killed.
- The desktop shell waited for the pump's `Failed`/`Ended`, but the pump was parked
  inside the blocking `NativeClient::connect*`, which had no abort. That is 15 s on a
  normal dial and 185 s on a request-access connect the host holds pending approval.

Two halves, both at the point every caller routes through:

`pf-console-ui` drops the takeover itself on Back. Cancel is the user's decision and
needs no confirmation from the wire; the `CancelConnect` action still goes out, and
every embedder already handles a dial that lands afterwards (quit-close the connector,
route the end back silently). With no waiting state left to render, `Connecting.canceling`
and the "Canceling…" card go with it.

`NativeClient::connect_with_audio_format` takes an optional abort switch, polled while
the call is blocked, and the session pump passes its stop flag — so the embedder's cancel
now reaches a dial that has not landed yet instead of being answered whenever it does.
Taking it is the same give-up as running out of budget (quit close + shutdown), so the
worker stops re-dialing and the host tears down rather than lingering for a reconnect
nobody wants. Deliberately NOT aliased onto the client's own `shutdown`: the pump reads
that to mean "this connection died" and derives the session's end reason from it, which a
caller-set flag would race — a dropped link would have reported no reason at all.
2026-08-19 12:24:25 +02:00
enricobuehler 9ddf802665 fix(plugin-kit): a restart or a Sync-now republishes, instead of answering "no changes"
The sync engine's fingerprint says we would compute the same entries again. It does
NOT say the host still holds them — and the host may accept a payload and store less
of it than was sent: an art path outside its allowed roots is stripped and the games
kept (deliberately, a cover must not cost a library), a launcher tile it cannot open
is dropped the same way.

Once that happens the fingerprint is a permanent "no changes". The operator fixes the
host side, nothing republishes, and the only way out is to delete the plugin's cache
file — which is exactly the advice a portable-Playnite library with 70 dropped covers
was given.

So the two triggers with a person behind them always apply: `startup`, the restart
everyone reaches for, and `manual`, the console's Sync-now button and the CLI's
`sync`. Both mean "publish my library NOW", and "no changes" is the wrong answer to
that. The loop reasons keep the short-circuit, which is where it earns its keep —
they are what would otherwise PUT the whole library every few minutes.
2026-08-19 12:20:00 +02:00
enricobuehler b6ca692c13 docs(abi): ex11's summary still stated the pre-2026-08-16 rule its own warning corrects — a format at all sets the hi-res cap 2026-08-19 11:54:45 +02:00
enricobuehler f7eb844274 fix(host/library): a portable Playnite's covers survive the art confinement
A Playnite unzipped outside the users base (D:\Apps\Playnite) keeps its whole
library beside the exe, so every cover it exports lives at
`<PlayniteDir>\library\files\…` — outside every default art root. The games
synced and all 70 covers were dropped, with only PUNKTFUNK_LIBRARY_ART_ROOTS
as a way out.

The Playnite install dirs are now art roots too, exactly as Steam's install
root already is, and `playnite_install_dirs` learned to find a portable copy at
all: it registers no uninstall entry and sits under no profile, but it does
register the `playnite://` handler — the very registration this host's launch
path already follows to start a Playnite title. So the same probe also gives a
portable install its Fullscreen launcher tile, which it never had.

The confinement is not loosened: the roots come from the host's own registry
and filesystem probes, never from the plugin lane that supplies the art path,
and the extension, regular-file, magic-byte and config-dir gates all still
apply.
2026-08-19 09:03:57 +02:00
enricobuehler a75ed71428 docs(kde): name the 6.6 change behind the KWin virtual-output failure — the output must now be enabled, not just created 2026-08-19 09:01:12 +02:00
enricobuehler b551f7dae8 fix(host): a translated KWin refusal burned all 8 retries — match our own prefix, not KWin's message
KWin sends the `failed` reason on zkde_screencast localized. The retry
short-circuit matched the English "could not find output", so a pt-BR session
("Não foi possível encontrar saída") fell through to 8 attempts over ~11 s and
then reported "out of retries" — a config fact dressed up as a flaky one.

Match "KWin virtual output failed" instead: our own prefix, never translated.
Every `failed` KWin sends on this path is a backend/config fact (unsupported
compositing type, a backend without createVirtualOutput, an output the
workspace declined to enable), none of which a retry 500 ms later changes.

The message itself now says what KWin's untranslatable reason means, and the
KDE docs gain the KWin 6.6+ shape of it: KWin creates the output, leaves it
disabled, and workspace()->findOutput() returns null — reported identically to
a backend that cannot create one at all.
2026-08-19 08:54:02 +02:00
enricobuehler e5046a2811 Merge pull request 'Android streaming: HDR and SDR colours through the ASC presenter, and the system bars stop parking over the video' (#319) from worktree-android-asc-hdr-dataspace into main 2026-08-19 06:24:23 +00:00
enricobuehler 19d37c44b3 style(client/android): rustfmt the display import block 2026-08-19 08:21:35 +02:00
enricobuehler 5be399a4f6 Merge remote-tracking branch 'origin/main' into worktree-android-asc-hdr-dataspace 2026-08-19 08:21:22 +02:00
enricobuehler 6c32890014 Merge pull request 'The controllers test compiles again: a closure can't name the lifetime its Ctx borrows' (#321) from worktree-console-ui-ctx-lifetime into main
Reviewed-on: unom/punktfunk#321
2026-08-19 06:18:40 +00:00
enricobuehler 9c33bc9397 Merge pull request '0.31.0' (#320) from worktree-release-next-prep into main
Reviewed-on: unom/punktfunk#320
2026-08-19 06:18:19 +00:00
enricobuehler fcdb2a53de fix(console-ui): the controllers test builds its Ctx in a fn, not a closure
A closure cannot be generic over the lifetime in its return type, so the
`Ctx<'_>` this one built could never be tied to the `&mut Settings` it
borrows — `lifetime may not live long enough`, and the lib test target
failed to compile.

Same shape the add_host and library test modules already use: a plain
`fn ctx<'a>(…) -> Ctx<'a>` where the lifetime is written down once.
2026-08-19 08:17:10 +02:00
enricobuehler 601f040ffe release: 0.31.0 — re-cut on 790db5ed (#318 merged after the first cut)
90 commits since v0.30.0 (65 non-merge), was 88/64. #318 installed the
Nix cache signing key (public half now pinned in the docs and served by
the cache) and moved its DNS provisioning into unom/infra's OpenTofu; the
CHANGELOG's Nix paragraph says so instead of listing the PR as absent.
The notes needed no change: their NixOS line already pointed at the
install guide and the cache-served key. Version table unaffected — #318
touches docs and the setup wizard only.
2026-08-19 00:20:40 +02:00
enricobuehler eac308412c Merge remote-tracking branch 'origin/main' into worktree-release-next-prep 2026-08-19 00:20:08 +02:00
enricobuehler 65c4b4b17e release: 0.31.0 — version bump, notes, CHANGELOG, Play notes
88 commits since v0.30.0 (64 non-merge). Cut from origin/main 8a4eac4c
(PR #317 merged; PR #318, the Nix cache signing-key follow-up, is open
and NOT in this cut).

THE NUMBER: 0.31.0 is a judgement call, not forced. Nothing versioned
moved — WIRE_VERSION 2, C ABI 24 with include/punktfunk_core.h byte-
identical to the v0.30.0 tag, driver protocol 6 / min 3 (pf-driver-proto
has no diff against the tag), gamepad channel 3, plugin index schema 1,
host event schema 1, gamescope +pfhdr8 with no new patch files,
api/openapi.json still stamped 0.29.0 and unchanged, SDK 0.1.4,
plugin-kit 0.4.2. On that table alone this could be 0.30.1. It is a
minor because the cycle is feature-shaped and one commit carries a `!`:
the Android Compose console is deleted outright and pf-console-ui over
Skia/GL becomes the console on all three ABIs (#301, #317); the Android
present path moves to ASurfaceControl by default (#290); the Linux
desktop-audio capture flips to a host-owned null-audio-sink by default
with PUNKTFUNK_STREAM_SINK=stream as a one-release escape (#305);
`topology: exclusive` on Hyprland/sway goes from silently-extend to
genuinely disabling the operator's outputs, which is the default policy
on every auto-detected box (#304, closes #284); a Nix binary cache
ships (#313); and the Linux host package grows three new system files
(udev rule, WirePlumber policy, ALSA UCM drop-in) that the DualSense
audio path depends on (#291, #302, #315). scripts/ci/pf-version.sh
already derives the canary base as latest stable + one minor, so
canaries have sat at 0.31.x since the v0.30.0 tag and move to 0.32.x
after this one.

Version table re-measured on 8a4eac4c, not carried forward. Two rows
carry a warning rather than a change: sdk/ and plugin-kit/ both have
real code changes (the mgmt-endpoint follow in config.ts/runner-cli.ts;
SyncSettings.minInterval in sync-engine.ts) with package.json unbumped.
They version off their own sdk-v*/plugin-kit-v* tags and this commit
does not touch them; the CHANGELOG names sdk-v0.1.5 and
plugin-kit-v0.4.3 as owed so they are cut deliberately.

The release branch also carries one preceding whitespace-only commit:
crates/pf-console-ui/src/screens/controllers.rs and
crates/punktfunk-host/src/audio/linux/pad_card_volume.rs had landed on
main formatted differently from rustfmt 1.96.0 (the pinned toolchain),
so ci.yml's Format step was red on the tip this is cut from. `cargo fmt
--all` and nothing else.

Gates run on this tree (this MacBook, rustc/rustfmt 1.96.0): cargo fmt
--all --check clean; cargo metadata --offline ok; Cargo.lock diff
versions-only (36/36 lines); cargo test -p punktfunk-core unit suite
272 passed; the android.yml Play notes gate run verbatim — 498/500
characters and not byte-identical to any prior release's; notes voice
scan finds no internal names outside the For developers section (the
two hits, `nixpkgs` and Flatpak, are things those users configure by
name); both openapi copies cmp identical and unchanged since the tag;
include/punktfunk_core.h regenerated by the build and git-diff clean.

⚠ NOT run here: the C ABI harness (tests/c_abi.rs) — it links -lopus
and this machine has no libopus (`ld: library 'opus' not found`; the
Mac Studio has it). The header is byte-identical to v0.30.0's, where the
harness passed, and nothing in punktfunk-core's C surface changed; the
CI runner is its first execution for this tag. Named in the CHANGELOG's
Verification status rather than left to be discovered.

No outside contributor is credited by name: every attribution in the
64 bodies is a dated field report or "a reporter", and issue #284 was
filed by the maintainer. The Thanks section thanks the field reports
without naming anyone.
2026-08-19 00:18:08 +02:00
enricobuehler 329df4c1f4 fix(client/android): SDR through ASC presented untagged buffers — SurfaceFlinger's full-range guess elevated the blacks
The SurfaceView path never tagged SDR because MediaCodec tags its own window
buffers; with AImageReader → ASurfaceControl the transaction is the only
carrier, and dataspace 0 meant setBufferDataSpace was never called. An
untagged limited-range BT.709 buffer read as full range shows black (16) as
gray. SDR now maps to ADATASPACE_BT709 (limited-range video) in
color_dataspace — every ASC buffer is tagged.
2026-08-19 00:17:26 +02:00
enricobuehler 5fc5da3256 fix(client/android): one owner for the system bars — the console's dispose re-showed them over the stream
Console → stream rides an AnimatedContent cross-fade, so the outgoing
console shell stays composed until the fade ends: its
onDispose { show(systemBars()) } fired AFTER StreamScreen's hide, parking
the status bar and the gesture bar over the video for the whole session.

The hide/show now lives once in App.kt, keyed on the resolved intent
(streaming or console fronting = immersive; touch shell = bars back), and
both screens' per-screen bar management is deleted.
2026-08-19 00:04:55 +02:00
enricobuehler a8922b454a fix(client/android): the ASC presenter tagged HDR from the codec's echo, not the negotiated colour
The negotiated ColorInfo is the wire contract's authority on the stream's
colour, but the ASC backend seeded a hardcoded BT2020_ITU_PQ guess and then,
on the first output-format change, overwrote it with whatever the codec
echoed — and a decoder that omits color-transfer (common) echoed None, which
clobbered the dataspace to 0 before the first present. Untagged P010 buffers
composited as sRGB: broken HDR colours. An HLG stream was also mis-seeded PQ.

Now the initial dataspace derives from client.color (PQ vs HLG, range), and
a format change only refines it when the codec actually reports an HDR
transfer — never resets it, matching the SurfaceView path's semantics.
2026-08-19 00:04:49 +02:00
enricobuehler 790db5edbb Merge pull request 'The cache signing key is installed, and its DNS was never a dashboard click' (#318) from worktree-nix-binary-cache into main
Reviewed-on: unom/punktfunk#318
2026-08-18 22:04:09 +00:00
enricobuehler 82d39011ce style: cargo fmt drift in pf-console-ui controllers screen and pad_card_volume
Two files landed on main formatted differently from rustfmt 1.96.0's
output, so ci.yml's Format step fails on the tip the release is cut
from. Whitespace only; no behaviour change.
2026-08-18 23:59:53 +02:00
enricobuehler 8a4eac4c41 Merge pull request 'The Android console stops losing the pad, owns its Controllers page, and takes the whole phone panel' (#317) from worktree-phone-console-ui into main
Reviewed-on: unom/punktfunk#317
2026-08-18 21:50:54 +00:00
enricobuehler 7e4fe80793 feat(nix): install the cache signing key and correct how its ingress is provisioned
Two corrections and one thing actually done.

DNS here is not a dashboard click. unom/infra owns the unom.io zone in OpenTofu
(terraform/cloudflare/records.tf, applied by dns-cutover.yml), and that file's
`local.hostnames` set carries its own invariant: "a name here with no vhost 404s,
a vhost with no name here never cuts over." A record added by hand in Cloudflare
is out-of-band and risks the duplicate-record round-robin the file documents a few
lines further down — the same class of trap as hand-editing ~/caddy/Caddyfile on
the box. The setup steps said "in the unom.io Cloudflare zone" as though it were a
manual change; they now name both files, the workflow that applies them, and the
one-added-record check to expect from `plan`. unom/infra#20 makes the change.

The signing key is generated and `NIX_CACHE_SIGNING_KEY` is installed as a repo
Actions secret, so its public half is no longer a placeholder:

    punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=

pinned in both docs. The publish step still writes the same value to
/punktfunk-cache.pub, so the docs can always be checked against the cache itself —
and the wizard now compares the two and warns on a mismatch, because docs that
disagree with the cache mean users reject everything it serves.

The wizard drops to four stages. DNS and the vhost were separate stages when they
looked like separate manual steps; they are one PR against one repo, so they are
one stage. The key stage now detects the installed key, prints it, and refuses to
casually regenerate — a new key invalidates every signature already published and
breaks every user pinning the old one.

Verified: shellcheck + `bash -n` clean, 4 stages against TOTAL_STAGES=4, and the
already-installed path's key extraction tested against the real README.
2026-08-18 23:49:21 +02:00
enricobuehler c4cf53c1fc Merge pull request 'The Nix cache's setup steps pointed at a home-lab proxy that no longer exists' (#316) from worktree-nix-binary-cache into main
Reviewed-on: unom/punktfunk#316
2026-08-18 21:32:53 +00:00
enricobuehler d59a1a9606 feat(console-ui): the console takes the whole phone panel, and the library stops spending it
Four phone-sized fixes, one theme — a small screen was paying for chrome it
never asked about:

- The Android shell hides the system bars while it fronts the app (transient
  by swipe, restored on dispose), the same contract as the stream. This is
  also the safe-area fix: hidden bars report zero insets, so scrolled content
  no longer slices off at the visible gesture-bar line with bare backdrop
  below — only the display cutout remains a real inset.
- The design-unit scale's phone density floor rises 0.6 -> 0.75: on a 460 dpi
  panel the floor is what sets the scale (the couch term only wins on tablets
  and TVs), and 0.6 read a step too small in the hand. Still the documented
  on-glass knob.
- The library's sort/view bar appears only while it holds the pad (up from
  the field / the legend's 'Sort & view'), the Apple client's behaviour; the
  field takes the band's height back as it fades.
- The store/platform subtitle under the focused title is gone — the cover
  badge already says it — and the detail band shrinks 84 -> 64 units, most of
  a grid row on a phone.

Plus a guard the new scale makes necessary: the grid's two-column minimum
shrinks its covers to fit rather than clipping at the edges when a narrow
viewport times a high density leaves less width than two full cells.
2026-08-18 23:27:44 +02:00
enricobuehler 7c411f7ef4 fix(nix): the cache's setup steps described a topology that no longer exists
The bring-up instructions were copied from packaging/flatpak/README.md, which
still describes an edge proxy on `home-reverse-proxy-1` forwarding to
192.168.50.50. That home-lab topology is gone. packaging/winget/server/
compose.production.yml — the newest of the three and the only one written since
the move — says so outright: "the sibling docs/flatpak compose files still carry
stale comments … the public hostnames resolve straight to the hcloud box and are
served by Caddy there — no local proxy is involved." flatpak.unom.io resolves to
167.233.145.172, which is unom-1 itself, confirming it.

So the steps now match how docs and winget were actually stood up:

  * DNS in the unom.io Cloudflare zone, DNS-only, straight at the hcloud box.
  * The vhost in unom/infra `caddy/Caddyfile`, proxying to localhost:3250 —
    NOT 192.168.50.50, and NOT hand-edited on the box. ~/caddy/Caddyfile there
    looks like the config but is an rsynced copy with no .git to warn you; a
    vhost added only on the box lasts until the next deploy. That is how the
    winget source vanished on 2026-07-26, and it is now called out here too.
  * `caddy_target_ports` + terraform is dropped. It was the home-lab firewall
    allowlist; winget's setup, written post-move, has no such step.

Also adds the SNI diagnostic winget's README hard-won: Caddy 308s every Host on
:80 to https, including names it has never heard of, so probing port 80 proves
nothing — check the certificate by SNI instead.

scripts/setup-nix-cache.sh walks the five steps interactively (built from the
/wizard template): it opens each page, says exactly what to click, and verifies
each stage before moving on, because the failure signatures are easy to confuse
— a TLS handshake failure means the vhost is missing, a 502 means the container
is down, and a 404 means the cache is healthy and empty.

It also closes the loop the first version left open: it generates the signing
key locally (a local nix, or the nixos/nix image — MEASURED: both produce the
`name:base64` line, and convert-secret-to-public round-trips), then writes the
PUBLIC half straight into the two docs that carried a `<fill-in>` placeholder.
Nobody has to wait an hour for the first publish to print a value we can derive
up front. The secret half is shown once for pasting into Gitea and never
touches disk. Re-running detects an installed key and refuses to silently
replace it, since that would invalidate every signature already published.

Verified: shellcheck clean, `bash -n` clean, 5 stages against TOTAL_STAGES=5,
and the doc substitution tested against a real generated key — public keys are
base64 and contain `/`, so the sed uses `|` as its delimiter.
2026-08-18 23:26:06 +02:00
enricobuehler 9e47f746ba Merge pull request 'The DualSense's only playback route was a mono sink games overran, and both ends met the pad at -24 dB' (#315) from worktree-dualsense-handoff into main
Reviewed-on: unom/punktfunk#315
2026-08-18 21:22:08 +00:00
enricobuehler 13f8a1c5cd Merge remote-tracking branch 'origin/main' into worktree-dualsense-handoff 2026-08-18 23:18:07 +02:00
enricobuehler d5f2c63367 Merge pull request 'A moved mgmt port left every plugin and the tray dialing 47990 in silence; the Windows runner task now also writes a log file' (#314) from worktree-windows-runner-log-file into main
Reviewed-on: unom/punktfunk#314
2026-08-18 21:16:56 +00:00
enricobuehler 4e03dcc280 Merge pull request 'NixOS users compiled the whole workspace because we published no binaries' (#313) from worktree-nix-binary-cache into main
Reviewed-on: unom/punktfunk#313
2026-08-18 21:15:54 +00:00
enricobuehlerandClaude Opus 5 37813199b5 Merge origin/main — the WirePlumber DualSense policy and the UCM drop-in are complements
Three packaging conflicts, all the same shape: #307 added a
`60-punktfunk-dualsense.conf` install at the exact line this branch added the
ALSA UCM install to. Both sides kept — they act on different layers and neither
subsumes the other:

  * the WirePlumber rules govern how the pad's nodes BEHAVE once they exist
    (`node.always-process` so GE-Proton's raw open cannot race itself,
    `priority.driver = 0` so a pad never clocks somebody else's graph);
  * the UCM drop-in governs WHICH nodes exist at all (a `SpeakerHaptic` device
    at priority 200, so the 1-channel sink games overrun is never minted).

Checked rather than assumed: the drop-in's node-name matchers
(`~alsa_output.usb-Sony_Interactive_Entertainment_DualSense.*`) still match the
sink the UCM change introduces — `…DualSense_Wireless_Controller-00.HiFi__
SpeakerHaptic__sink` — so the policy follows the pad onto the new profile. And
neither touches volume, so the 0 dB pin on this branch is untouched by both.

The Android side of #301 deleted the Compose gamepad mirror, not
`SettingsScreen.kt`, so the "Controller speaker" subtitle survives; the Skia
console that replaced it carries no speaker row of its own (it opens Android's
connected-controllers view instead), so there is no second place to say it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:15:17 +02:00
enricobuehler 9cefa0a3ea feat(console-ui): connected controllers is the console's own screen — input stays on the pad
The Controllers row used to raise the D7 platform-screen mechanism: Android drew
the Compose ConsoleControllersScreen over the surface and suspended the console's
input until it closed. Now the page is a shared Skia screen
(screens/controllers.rs) pushed like any other settings sub-screen, so the console
keeps its own navigation, transitions and haptics on it — and a future desktop
build gets it for free (the row itself stays Android-only in row_on).

What genuinely cannot move into Rust stays with the host, asked for by ONE
parameterised command, ConsoleCmd::PadAction { action, pad_key }: the USB and
Bluetooth grant dialogs (sc2_bluetooth / sc2_usb / ds_usb), the rumble pulse on
the real InputDevice, and the DualSense pad-audio self test. SkiaConsoleShell
handles them with the same helpers the touch Controllers screen uses (testRumble,
the grant intents, nativePadAudioSelfTest), reporting through the notice toast, so
the support answer cannot drift between interfaces. PadInfo carries the three
fields the screen needed and the aggregated list already lacked (detail line,
forwarded, rumble), filled by ConsoleJson.pads from the same padInfoOf reader the
touch screen renders from.

PlatformScreen::Controllers is gone; the mechanism itself stays for Licenses,
which still suspends the console's input correctly (the probes gate on platformUp
as before). The Compose console variant and its screenshot scenes are deleted;
the touch ControllersScreen keeps the full page INCLUDING the live input test,
which deliberately did not move — the console only receives the aggregated
MenuSample, nowhere near a per-device axis/trigger readout (ponytail note at the
top of controllers.rs records the upgrade path).
2026-08-18 23:14:23 +02:00
enricobuehler 83f6164027 Merge pull request 'The takeover's own mask was the relogin storm that starved pad input' (#312) from worktree-sddm-relogin-storm-fix into main
Reviewed-on: unom/punktfunk#312
2026-08-18 21:14:11 +00:00
enricobuehler dd097d1ef2 chore(nix): record disk headroom after the publish build too
This job is now the heaviest on the fleet — a full workspace build plus
gamescope fills the store with tens of GB, and this fleet ran a runner out of
disk on 2026-08-06. The pre-existing Environment step reads df before any of
that happens, which is the less useful of the two moments.
2026-08-18 22:43:47 +02:00
enricobuehlerandClaude Opus 5 01946aa123 fix(pad-audio): a silent pad speaker looked like broken hardware, not a setting
A field session spent an evening measuring the HOST — sinks, tone injection,
per-channel peaks — for a pad speaker that was switched off at the other end.
A disabled toggle and dead hardware are indistinguishable from the pad, and
nothing said which one it was.

Two hints, at the two places somebody actually looks:

  * `punktfunk-session --pad-audio-test` now says, before the tone, whether
    `pad_speaker` / `pad_haptics` would stop a real session rendering the very
    thing the tone is about to prove works. The devtest drives the pad DIRECTLY
    and is otherwise blind to the settings, which is exactly what makes "the tone
    plays here but the game is silent" so confusing. It has to be said up front:
    the capability is never advertised when the toggle is off, so no later log
    line can catch it.
  * The Android settings row now states its default in the subtitle. Android is
    the one client that defaults this off, and that is a deliberate form-factor
    choice (a small, easily-startling loudspeaker in the user's hands, unlike
    haptics duplicating audio they already hear) — so the default stands and the
    row stops being silent about it. `pf_client_core`'s `default_pad_speaker` is
    `"pad"` and always has been, so the desktop clients were never the ones
    hiding it.

Worth recording for the next time: the host ALREADY had the signal. `native/
pad_audio.rs` logs `pad audio streaming … haptics=<bool> speaker=<bool>` at
stream start, and a client with the toggle off shows up there as
`speaker=false`. It was being printed on the host that was being measured all
evening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:42:18 +02:00
enricobuehler c0dcac7fa2 fix(sdk,tray): follow the mgmt port the host actually bound — a moved PUNKTFUNK_MGMT_BIND left every plugin and the tray dialing 47990
Field report 2026-08-18, confirmed: the operator had moved the management API off 47990
(`PUNKTFUNK_MGMT_BIND` in host.env — the supported way to share a box with Sunshine/Apollo). The
web console followed, because it reads `<config_dir>/mgmt-endpoint`, the one line the host
publishes on every start with the port it REALLY bound. Nothing else did:

- The plugin runner / SDK resolved `PUNKTFUNK_MGMT_URL` → literal `https://127.0.0.1:47990`.
  The runner is a scheduled task (Windows) / systemd unit that inherits nothing from host.env —
  on Windows it cannot even read it — so every plugin, and the runner's own log shipper, dialed a
  dead port forever. Task Running, plugins never registering, empty library, and "no logs at all".
- The tray defaulted `--mgmt-port` to 47990 and told the operator to edit the autostart command
  line if they moved the bind. Nobody knows to do that; the tray reports a running host as
  unreachable.

One source, two readers, no new file:
- `sdk/src/config.ts::publishedMgmtUrl` reads `mgmt-endpoint`; `resolveConfig` uses it after the
  env override and before the 47990 default. Every plugin `connect()` follows, on every platform,
  with no unit/task changes. `runner-cli.ts` additionally exports it into `PUNKTFUNK_MGMT_URL`
  before any plugin loads, so a plugin still carrying an older vendored `@punktfunk/host` follows
  too (on Windows `reconcileSharedSdk` cannot refresh the read-only tree, so old copies can
  outlive several host upgrades). An explicit PUNKTFUNK_MGMT_URL still wins.
- `pf_paths::published_mgmt_port` (std-only leaf; the tray now depends on it) parses the same
  line. The tray's `mgmt_port` becomes `Option<u16>`: `--mgmt-port` pins, `None` re-reads the file
  on every poll tick, so a host restarted on a new port is picked up without relaunching the tray.

Swept the rest: the web console (`windows::service::spawn_web`, the systemd unit, NixOS module)
already sourced the file; the host CLI, plugin-kit (goes through the SDK), gaming-mode console
and native clients derive the port from discovery / the Welcome — no other literal remained on a
loopback path. The console's web port (47992) is not operator-configurable, so the tray's
literal there is not the same bug.

Verified: SDK 83 tests pass (4 new: absent file → default, published line followed, env wins,
blank = unset), `tsc` clean, biome clean; `pf-paths` unit test; `cargo fmt --check` clean;
`cargo clippy -p pf-paths -D warnings` clean; `cargo check -p punktfunk-tray -p pf-paths` on
Linux (docker rust:1.96) — the tray is cfg-gated off macOS. Not built on Windows from here.
2026-08-18 22:42:13 +02:00
enricobuehlerandClaude Opus 5 654c09d067 fix(pad-audio): WirePlumber met every new pad card at -23.88 dB, and both ends stacked
WirePlumber starts every new card's sink at `device.routes.default-sink-volume`.
That is 0.4, and 0.4 is a CUBED number: what a mixer shows as 40 % is 0.4^3 =
0.064 of linear amplitude, -23.88 dB. The setting is global — it cannot be
scoped to one device from configuration — so there was no config file to ship,
and it fires again on every fresh card, which for a usbip pad is every attach.

Reasonable for a laptop speaker somebody is about to turn up. Wrong here twice:
nobody chose it and nobody would think to look for it (a pad's sink is not a
listening volume anyone reaches for, so it reads as weak hardware), and BOTH
ends of a session mint one. The game's samples cross this sink on the host and
the pad's own sink on the client, so the two multiply: 0.064^2 = -47.8 dB by the
time haptics reach a voice coil, which matches the -48 dB measured in the field.
That is the difference between "the haptics are subtle" and "I am not sure the
haptics are connected".

Both ends now set `channelVolumes` to unity — 1.0, which is unity in the linear
units the pod speaks AND in the cubed scale a mixer displays, the one value that
is unambiguous.

  * Client (`pin_sink_volume`, called from `correlate_pad_sink`): at every pick,
    so a card that re-minted its nodes after a profile change or a replug is
    re-pinned without anything having to notice. Skipped for the `split_parent`
    pick — that is a name lifted off another node's proplist, with no object of
    our own behind it, and pinning the sink that named it would pin the wrong
    node.
  * Host (`audio/linux/pad_card_volume.rs`, started when `PadUsbCapturer::open`
    succeeds): the host half matters because `pad_usb` captures at the pad's
    isochronous OUT endpoint, DOWNSTREAM of this sink — PipeWire applies the
    volume when it mixes into the ALSA device, so what we encode and send is
    already attenuated and no client-side fix can recover it. It retries for 15 s
    rather than firing once: the USB device is live well before its ALSA card is,
    and pinning before WirePlumber has applied the default we are undoing would
    simply be overwritten. Only sinks belonging to a DualSense CARD are touched —
    `device.id` is what keeps it off a host's own minted pad sink, which
    publishes the full DualSense identity on purpose.

Neither end restores on the way out, deliberately: the profile swap next door
overrides a choice the user made, this overrides a default nobody made, and
putting -24 dB back would be restoring the bug. `PUNKTFUNK_PAD_SINK_VOLUME=0`
disables both, for bisecting a box where something else attenuates.

Both pods are unit-tested for the shape that actually matters — one unity float
per channel. PipeWire ignores a `channelVolumes` whose length does not match the
port count, and an ignored pod looks exactly like the pin silently not working,
which is the -23.88 dB back again and just as invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:42:06 +02:00
enricobuehlerandClaude Opus 5 ab88a8fb40 fix(pad-audio): the DualSense's only playback route was a mono sink, and games overran it
A wired DualSense on Fedora 44 / Bazzite / Arch presents exactly one playback
sink: the 1-channel `…Default__Speaker__sink`. GE-Proton mints its synthetic
"Sony controller speaker" endpoint from that lone mono sink, and Marvel's
Spider-Man Remastered overruns it — reliably, ~74 s in:

    73.846 render_GetBuffer (…)->(5034, …)   <- GE's mono endpoint
    73.846 EXCEPTION_ACCESS_VIOLATION  info[0]=1 (WRITE)  info[1]=5CB9A000

Not a format mismatch: `GetMixFormat` and the game's `Initialize` both agree on
mono float32 `nBlockAlign 4`, and pulse sized `maxlength: 20136` = 5034 x 4
correctly. At the fault `rsi=rbp=0x13aa` (5034, the frame count) while
`rcx`/`rdx` are 5206/5207 — the copy loop had already run past the count. It is
a game/GE bug on a code path that ONLY EXISTS WHEN THE MONO SINK DOES.

So delete the mono sink rather than chase the overrun. `alsa-ucm-conf` describes
the pad as Speaker / Headphones / Mic / Headset and has never carried a
`SpeakerHaptic` device — the DualSense profile arrived upstream in 1.2.15
(36a111a) already without it, and the Deck's is a Valve downstream patch they
still carry on their own 1.2.16.1. With `SpeakerHaptic` at `PlaybackPriority
200` against `Speaker`'s 100 the card takes `HiFi (Mic, SpeakerHaptic)`, the
sink is the 4-channel one, and the mono sink — with the crash path — never
exists. The voice coils reach their own channels as a bonus.

Shipped WITHOUT replacing a file `alsa-ucm-conf` owns, which is what made this
awkward to package. `USB-Audio/USB-Audio.conf` ends with an unconditional,
optional include of `USB-Audio/conf.d/{vid}-{pid}.conf`, placed after its device
table has chosen `${var:ProfileName}` and before it includes the profile that
name resolves to — so a two-line drop-in keyed by 054c:0ce6 / 054c:0df2 swaps
the profile with no diversion, no `Conflicts`, and no `%config` fight. Verified
against alsa-lib rather than assumed: `ucm_cond.c` makes `Condition` optional
for a syntax-v8 `If` carrying `Append`, and `uc_mgr_evaluate_include` evaluates
each included subtree in place before moving to the next include, so the
`Define` lands before the profile include substitutes the variable. The hook and
the DualSense profile shipped in the SAME release (1.2.15), so every tree that
has the bug has the hook.

Host packages only (rpm — and therefore the Bazzite sysext, which unpacks the
RPMs — deb, Arch). The client already has a working fallback in
`ensure_pro_audio`, and a shared file in two co-installable packages is a file
conflict for a nicety. NixOS is not covered: it has no /usr/share/alsa/ucm2 to
drop into and needs a package override instead.

`scripts/ci/check-dualsense-ucm.sh` runs the whole chain on a real distro tree
with no hardware, via UCM's card-less `conf.virt.d` path with only the four card
built-ins stubbed. Against pristine Fedora 44 alsa-ucm-conf 1.2.16.1: baseline
`Headphones/Headset/Mic/Speaker`; with the drop-in, `SpeakerHaptic` and
`HeadphonesHaptic` too, `PlaybackPriority/SpeakerHaptic=200` over `Speaker`'s
100, `PlaybackPCM/SpeakerHaptic=…dualsense_haptic_out:…,1,1,2,3`. It exists
because this fix hooks another project's dispatcher: an upstream rename would
neuter it silently, and what comes back is the crash, not a quieter pad.
Negative-tested both ways (typo'd ProfileName, hook deleted).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:41:46 +02:00
enricobuehler 66249710b9 feat(nix): publish a binary cache so NixOS users stop compiling the workspace
Every other channel ships prebuilt binaries; Nix was the exception — `nix build`
meant the whole Rust workspace *and* a gamescope build from source, roughly an
hour, and `host.gamescopeHdr` defaults true so that compositor build is on the
critical path of every `services.punktfunk.host.enable = true`.

nix.yml grows a third tier: on a push to main it builds the Rust packages plus
gamescope, signs them, and publishes to https://nix.unom.io. No new trigger is
needed for releases — a release bumps the workspace version in Cargo.toml, which
is already in the path filter.

Gitea cannot host this: it has 23 package registry types and none is Nix, and the
protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
/<hash>.narinfo, /nar/…) that /api/packages/{owner}/generic/… cannot express.
The RustFS at storage.unom.io would work mechanically — nix speaks
s3://…?endpoint= and the sccache credentials already exist — but it is a local
box on the home uplink with no CDN, so every user download would compete with CI,
and S3 answers 403 for a missing key unless the bucket policy grants anonymous
ListBucket. Nix treats anything other than 404 as a hard error rather than a
cache miss, so that would break users' builds for packages the cache never held.
So it goes on unom-1 beside the flatpak repo, as a caddy:2-alpine container
serving a static tree — which is all a binary cache is.

Three decisions worth keeping:

* Only punktfunk's own store paths are published. The rest of a runtime closure
  is stock nixpkgs, already on cache.nixos.org behind a real CDN; mirroring it
  would spend disk and home-to-cloud bandwidth to serve a worse copy. That is
  ~300 MB per publish instead of several GB. The step asserts every built output
  is matched by the name filter, so a future pname change fails the build rather
  than silently dropping the most expensive package from the cache.
* NARs upload before narinfos, and rsync runs without --delete. A narinfo whose
  NAR has not landed is a hard download failure for whoever fetches it in that
  window; a NAR nothing points at is merely invisible.
* prune.sh from the first publish, not after the box fills. The flatpak repo next
  door reached 3.84 GB publishing this same way with no sweep, on a box that has
  run out of disk before. It ages out narinfos, then sweeps unreferenced NARs —
  that order is the correctness argument, and it carries a self-check.

Verified locally: the Caddyfile serves a fixture cache with 200s on hits, 404 on
misses (the assertion the whole design rests on), and immutable cache headers;
prune.sh passes its self-test on Debian including empty and all-stale caches;
both workflows parse; the new run blocks pass shellcheck and dash -n. `set -eu`
rather than `-euo pipefail` — dash dies on the latter — with the two pipelines
whose left side must be able to fail rewritten as redirects.

Docs: README gains the substituter snippet, a maintainer runbook, and a warning
that inputs.punktfunk.inputs.nixpkgs.follows disables the cache entirely (every
store path changes, so every package rebuilds). The install guide gains the same
in short form.

The public key is a fill-in until the first publish prints it — see the setup
steps in packaging/nix/README.md.
2026-08-18 22:38:17 +02:00
enricobuehler 454531030d fix(android): a console that cannot draw yields to the touch UI instead of a gray screen
Connecting a controller could swap in the console shell over a SurfaceView
nothing would ever paint: the native create failing, the render thread dying,
or a GL context Android reclaimed all left the app on a gray screen for the
rest of the process — Kotlin only logged the Dead event.

SkiaConsole now exposes an observable [healthy] flag (false on create failure
or a Dead event) and App folds it into the gamepad-UI gate, so the touch UI
takes over. On the native side, a run of consecutive GL setup failures
(window surface / Skia wrap) — previously logged and retried forever, a hot
spin with a live surface — now ends the render thread through the same
release order as Quit, which raises Dead and hands the screen back.
2026-08-18 22:34:25 +02:00
enricobuehler a64a22ccfc fix(android): the console's pad probes survive the Controllers/Licenses pages
The MainActivity pad probes were one last-writer-wins slot. The Skia shell
installs its probes once (its effect keys never change); a Compose screen the
console opens over itself (Controllers, Licenses) overwrote that slot, and on
its way out nulled it — the shell never re-installed, so every gamepad press
after closing the page was silently dropped until the process died.

The slot is now a stack: each holder pushes its claim on install and removes
it BY IDENTITY on dispose, and dispatch consults the top. Whatever ordering
Compose produces — cross-fades composing both screens at once, non-LIFO
disposal — a leaving screen takes only its own entry, and the one underneath
resurfaces the moment it pops.
2026-08-18 22:34:10 +02:00
enricobuehlerandClaude Opus 5 3717466594 fix(gamescope): the takeover's own mask was the relogin storm that starved pad input
A managed takeover on the .41 SDDM-autologin box entered a ~4-5 logins/s
relogin storm and every udev consumer drowned in the fallout. It presented
as "my DualSense is not detected in the game, or only with an insane delay":
the pad enumerated perfectly and then delivered input at ~1.4 Hz instead of
250 Hz, because `winebus` re-enumerates udev on every event and therefore
stops reading `hidraw`. An evening went into disproving the pad stack, the
ALSA UCM, PipeWire and GE-Proton before the display manager was suspected.

The storm was ours. Measured on .41 (2026-08-18):

    /usr/share/wayland-sessions/gamescope-session-ogui-steam.desktop
      Exec=gamescope-session-plus ogui-steam
    /usr/share/gamescope-session-plus/gamescope-session-plus, last act:
      systemctl --user --wait start gamescope-session-plus@${CLIENT}.service

so the runtime mask this takeover lays sits *directly in sddm's relogin
path*. Every autologin then fails in milliseconds instead of taking the
seconds a real gamescope + Steam start costs, and sddm's `Relogin=true` has
no backoff: a slow, survivable relogin loop becomes a fork storm. 962 logind
sessions in 3.7 min, `Watching system buttons` re-scanned 5,688 times, a
box-wide udev `change` storm at ~20/s, iio-sensor-proxy crash-looping at ~16
starts/s as a udev-activated amplifier, load 26 on 12 cores.

The 2026-07-31 reading of the same box recorded the storm but concluded the
sddm helper "execs the session script directly, so the masked unit never
enters the picture". It does — one `systemctl` call further down. That is
why masking looked inert, and why it was left as the *degraded* takeover for
SDDM when the DM stop could not be achieved. Masking without the stop is not
a weaker defense. It is the storm's engine.

So the mask no longer substitutes for the DM stop, on any flavor:

* `dm_plan` loses its `mask` input, and with it `dm_survives_masked_unit` —
  the SDDM/plasmalogin split existed only to pick a degraded mode, and there
  is no longer one to pick. A planned DM stop that does not land now fails
  the takeover and the caller degrades to ATTACH, which is a fully working
  stream at the session's own mode. Fighting an autologin we cannot stop is
  strictly worse than not taking over: it costs the user their input plane.
* The mask is laid only after the stop has LANDED. Both failure arms bail,
  so reaching the mask proves no DM is up to relogin through it. That is
  also what keeps mask-fragile flavors safe — a stopped plasmalogin cannot
  trip its own start limit, and every restore path unmasks before restarting.
* `skip` is now `!any_live` on every flavor: killing loaded-but-inactive
  leftovers frees no Steam, and masking them under a running DM is the storm.

Which of the two candidates in the handoff this was is now settled, on the
box: not (b). `pkexec /usr/libexec/punktfunk/pf-dm-helper linger` run from a
sessionless `systemd --user` context — the host's own context — exits 0;
lingering is on, bazzite is in group `punktfunk` (965), helper and polkit
action are installed and correctly annotated. The privileged path works
there. It is (a), and the fix is the invariant above rather than a better
`any_live`, because the DM ending up alive next to our mask is the fault
whatever put it there.

`any_live` is tightened anyway, stated as the negative: systemd has exactly
two not-running ACTIVE states and the other four all mean the unit still
owns Steam. The old list missed `deactivating` (and `reloading`), so a unit
caught mid-teardown read as a dead leftover — a box that IS in gaming mode
sampled as idle, its Steam left holding the single instance our own launch
then collides with. Small window on an idle box, wide open on a churning
one, which is exactly when this is sampled.

Two things the handoff asked for that are about never spending that evening
again:

* `DmHelperError::shape()` — the four shapes need four different fixes
  (package it / install polkit / fix the action / join the group), so they
  ride along as a greppable `shape` field, and a planned-but-unachieved DM
  stop now logs at ERROR naming it instead of vanishing into a WARN.
* `watch_for_relogin_storm()` — logind names its session files after a
  monotonic id in `/run/systemd/sessions`, so the max is a free login
  counter. Two `read_dir`s five seconds apart on a detached thread; above
  1/s it says so at ERROR, and says what it means: no audio, input or
  PipeWire measurement taken during a storm is valid. Detect-and-report
  only — self-mitigation would tear down a live stream on a detector that
  has never been wrong in the field because it has never run there.

Finally, `systemctl_system` captures its stderr and logs it at DEBUG. On an
unprivileged host that verb is *expected* to fail — it is the cheap probe
before the pkexec helper — so systemctl's own "Access denied ... requires
interactive authentication" went to the journal on the normal, successful
path: two of them immediately before `INFO restored the display manager`.
That shape cost two sessions on its own, each spent explaining a failure
that had already succeeded one line later.

Checked in the CI image (linux/amd64): `cargo fmt --check`, `cargo clippy
--all-targets -D warnings`, and `cargo test -p pf-vdisplay --lib gamescope`
— 52 passed, 1 ignored (needs a live `systemd --user` manager).

Refs: punktfunk-planning design/sddm-relogin-storm-starves-input-handoff.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:29:01 +02:00
enricobuehler c7c9500e89 fix(windows/scripting): the runner task writes a log file, so a runner that can't reach the host is no longer silent
Field report 2026-08-18, Windows host on 0.30: PunktfunkScripting task Running, Playnite and
Steam plugins installed, library empty, and "no logs at all for plugins" — nowhere on the box.

That is by construction, not by accident. The runner's only log door is the log shipper, which
tees console output to `POST /plugins/logs` over the mgmt API; the scheduled task itself had no
console and no file. So every failure that stops the runner reaching the host — LocalService
lost its read grant on plugin-token / native-cert.pem, a moved mgmt bind, a TLS pin miss, a
401 — is exactly the failure the shipper cannot report, and it leaves the same picture:
task Running, plugins never registering, an empty grid, and nothing to send when asked for logs.

`scripting-run.cmd` now redirects the runner's stdout+stderr to
`%ProgramData%\punktfunk\plugin-state\runner.log`, keeping the previous run as `runner.log.1`.
plugin-state is the one directory `plugins enable` makes writable for LocalService, and it
inherits Users-read from the config dir, so the operator can `type` it from any prompt.
Writability is probed with `copy /y nul` first; if the dir is not writable (the task was started
by the installer before `plugins enable` ever ran) the runner starts unlogged as before rather
than not at all. No `goto`: the file is stored LF and cmd's label scan is unreliable there.

The console's empty-Plugins hint (en/de) and the plugin docs now name the file; the log-ship
header no longer claims the task writes no file. Verified by reading only — no Windows box
reachable from here; the cmd semantics used (`copy nul` as a write probe, `if defined` blocks,
leading redirect on `echo`) are the boring ones.
2026-08-18 22:26:08 +02:00
enricobuehler cfbde6aec7 Merge pull request 'Tell the agents where the issues live: AGENTS.md and docs/agents/' (#310) from worktree-agents-md-setup into main
Reviewed-on: unom/punktfunk#310
2026-08-18 19:33:35 +00:00
enricobuehler 0eb8f2d0f1 docs(agents): AGENTS.md — where the issues live, what the labels mean, which docs to read first
The mattpocock engineering skills (/triage, /to-tickets, /to-spec, /wayfinder,
/diagnosing-bugs, /code-review) all assume a per-repo configuration that this
repo never had: they need to know which tracker to talk to, which label strings
carry the five triage roles, and which docs to read before exploring. Without it
each of them guesses, and the guess is GitHub.

Which is wrong here in a way that fails quietly. This repo's remote is
git.unom.io — Gitea. `gh` is installed on the machine and will happily run
against github.com and find nothing; `glab` and `tea` are not installed at all.
So the tracker doc points at the connected `gitea` MCP server instead, and says
so in the first line, because the failure mode is an agent confidently reporting
an empty issue list.

Two traps in that MCP surface are worth the words they take:

  - `issue_write` applies labels by numeric ID and `remove_label` takes a
    `label_id`, while `list_issues` filters by label *name*. Applying a label
    therefore needs a `label_read` lookup first — passing the name silently
    gets you nowhere.

  - unom/punktfunk has no labels defined at all, on the repo or on the org
    (checked both). The first triage run has to create the five before it can
    apply any of them.

Gitea writes are outward-facing — a shared instance that mails on activity — so
the doc encodes the standing rule as a gate: reads are free, every write waits
for a go-ahead, subagents included.

Domain docs are single-context: one CONTEXT.md and one docs/adr/ at the root.
Neither exists yet and neither should be created pre-emptively; /domain-modeling
writes them when a term or a decision actually gets resolved. Twenty-seven
crates and nine client platforms is the shape of a repo that eventually wants a
CONTEXT-MAP.md, so the switch path is written down — but they serve one domain
today, and a glossary split four ways before it has a single entry is just four
empty files.

AGENTS.md rather than CLAUDE.md because neither existed, and the cross-tool
convention costs nothing here.
2026-08-18 21:31:48 +02:00
enricobuehler 59cc234055 Merge pull request 'Clicking a host connects to it again; the library goes back to the card menu' (#309) from worktree-revert-host-card-primary-connect into main
Reviewed-on: unom/punktfunk#309
2026-08-18 19:20:30 +00:00
enricobuehler 242292528c fix(clients/apple): clicking a host connects to it again; the library goes back to the menu
Reverts the primary-action swap from 22fdea66: a host card's tap ran
`onBrowseLibrary ?? onConnect`, so on Mac, iPad and Apple TV clicking a paired
machine dropped you on its game shelf and streaming the desktop had moved into
the context menu. That inverted the pattern every other surface still uses.

Back to: tap the card = connect, "Browse Library…" = a context-menu action
(both on the host's own card and on a pinned host+profile card, where its shelf
still launches with that card's profile).

No other client had drifted — the Rust console shell (Linux + the Android Skia
port) keeps A = connect and Y = library, the GTK card's activate emits
`CardOutput::Connect` with "Browse library…" in the overflow menu, Android's
Compose grid taps to connect with the same menu item, and the Apple console
shell's tiles activate into connect. Only the touch/desktop `HostCardView` had
the swap, which is why one fix covers all three Apple platforms.

Everything else the library round-trip landed stays: the wake-on-open retry, the
per-host cached catalog, the Resume badges, the scroll memory.

Verified on the Mac: swift build clean, swift test 375 tests / 0 failures.
2026-08-18 20:09:08 +02:00
enricobuehler 0f7d724154 Merge pull request 'The virtual DualSense wore a placeholder USB serial no real pad has' (#307) from worktree-usbip-dualsense-fix into main
Reviewed-on: unom/punktfunk#307
2026-08-18 17:50:00 +00:00
enricobuehler 475ff70a2a fix(pad): the virtual DualSense wore a placeholder USB serial no real pad has
`UsbDevice::default` in the vendored server fills iSerialNumber with the string
"Serial". A real DualSense reports none — this file's own doc comments assert that
twice — and ALSA bakes the value into the card id, so the pad presented as
`…DualSense_Wireless_Controller_Serial-00` where the hardware gives
`…DualSense_Wireless_Controller-00`. PipeWire then carries the difference into
every node name and into `device.serial`.

Clearing it makes every name a matcher can key on byte-identical to a physical
pad's.

Scope, honestly: this is fidelity, not a fix for anything currently broken. It was
written when the `_Serial` infix was a suspect for the dead haptics; it is not the
cause. GE-Proton's winepulse leg has since been observed matching
`alsa_output.usb-Sony_Interactive_Entertainment_DualSense_Wireless_Controller_Serial-00.Default__Speaker__sink`
with the placeholder present, so nothing depends on this today. It removes a
gratuitous difference from real hardware rather than fixing a symptom.

Also NOT a fix for UCM profile selection, though that was my first reading:
`alsa-ucm-conf` keys on `${CardComponents}` (`USB054c:0ce6`), so `DualSense-PS5.conf`
matched with the placeholder still present. Which profile a card lands on is decided
by verb priority, not by its name. The comment records that so it is not re-derived.

Gated with `cargo clippy -p pf-inject --all-targets -- -D warnings` in
`punktfunk-rust-ci:latest` (linux/amd64): exit 0.
2026-08-18 19:48:13 +02:00
enricobuehler d4db2e3331 Merge pull request 'The RPM %install tried to execute a %files path, so main builds no RPMs' (#306) from worktree-rpm-spec-stray-install into main
Reviewed-on: unom/punktfunk#306
2026-08-18 17:18:01 +00:00
enricobuehler c49b648032 fix(packaging/rpm): drop the stray %files path that %install tried to execute
The WirePlumber DualSense policy landed with its %files line duplicated into
%install, one line above the real install. rpm ran it as a command, and the
%install scriptlet died on 'No such file or directory' before it reached
anything else.
2026-08-18 19:13:59 +02:00
enricobuehler c94dafd4be Merge pull request 'The Linux capture sink drives its own PipeWire group — and says so when something else does' (#305) from worktree-audio-sink-clock-isolation into main
Reviewed-on: unom/punktfunk#305
2026-08-18 16:00:06 +00:00
enricobuehler abb084aac3 Merge pull request 'topology: exclusive was echoed back by the API and dropped on Hyprland and sway' (#304) from worktree-vdisplay-topology-wlroots-hyprland into main
Reviewed-on: unom/punktfunk#304
2026-08-18 15:54:11 +00:00
enricobuehler 3eede724d1 docs(host/audio): say what the driver-id line can and cannot promise
The comment claimed the daemon republishes node props "whenever the graph is
recalculated". It does not, quite: pw_impl_node_set_driver writes the key and marks the
props changed, but leaves the flush to the node's next info emission — in practice the
state change that accompanies the same recalculation. Near enough for a diagnostic, and
exactly the kind of near-enough that a later reader deserves to be told about rather than
discover. Also retires four "in stream-sink mode" phrasings that now describe both sink
modes.
2026-08-18 17:49:48 +02:00
enricobuehler 4b5a37dae2 feat(vdisplay): topology: exclusive was echoed back by the API and dropped on Hyprland and sway
Both wlr-family backends accepted the topology axis, the management API reported
it as the session's effective topology, and the backend logged a warning and did
nothing (sweep 13.18 shipped the warning, never the behaviour). Because
`resolve_topology` sends `auto` — the default — to `Exclusive` on any host without
a `PUNKTFUNK_COMPOSITOR` pin, and both compositors are auto-detected, the default
policy on every such box was an Exclusive that behaved as Extend. Closes #284.

`exclusive` now disables the operator's heads for the session and restores them
when the display GROUP's last member is torn down, via the same
`take_topology_restore` hand-off KWin uses — so the registry runs the restore
before the last output is reclaimed and the compositor never sees zero enabled
outputs, and a sibling session never has the desk re-enabled under it.

The disable filter is group-aware (design §6.1): enabled, not ours, and not
managed. On Hyprland `managed` is `PF-<pid>-<n>`, which covers a second host's
outputs too; on sway it is the `HEADLESS-` prefix, which also spares a headless
sway's own bootstrap output — the harmless failure, versus blacking out a live
sibling.

`primary` stays treated as extend, which is the honest answer rather than a gap:
Wayland has no primary-output concept and these compositors have only a focused
output, which the streamed head already holds since #283. It now says so
distinctly instead of sharing a warning with `exclusive`.

🛑 The Hyprland restore is `hyprctl reload`, and that is measured, not chosen.
Re-applying the head's own mode/position/scale — what design §5.2 and the issue
both assume — does NOT undo a disable: it answers `ok` and leaves `disabled:
true`. Probed 2026-08-18 against 0.56.2 (hyprlang) and 0.55.4 (Lua); every
targeted form was accepted and changed nothing, including `,enable` (answers
`invalid resolution`), `preferred,auto,1`, `monitorv2 disabled=false`, `keyword
unset monitor`, the Lua `disabled = false`, `dispatch dpms on` and
`forcerendererreload`. A runtime monitor rule is additive and the `disable` keeps
winning; only re-reading the config clears it. The headless output survives the
reload, so the issue's worry about losing it does not hold. Side effects are
documented at the call site and in the docs: other runtime `keyword` overrides are
dropped, and a hyprlang config re-runs its `exec =` lines. It runs only when a
session actually disabled something.

Disable is spelled per config era and confirmed by read-back, mirroring
`set_monitor_rule`: `keyword monitor <n>,disable` under hyprlang, `hl.monitor{
output = "<n>", disabled = true }` under Lua. Both eras reject the other's form at
exit 0, so the read-back — not the exit status, not the `ok` — is what decides.

Also fixes a marker gap that made one of those rejections read as success:
`hyprctl keyword` under the Lua config manager answers "keyword can't work with
non-legacy parsers. Use eval.", and `hyprctl_dispatch` matched "couldn't" but not
"can't". `set_monitor_rule` was covered by its own mode verification; nothing else
was.

⚠ The sway half is NOT exercised on a live sway — no box in the fleet runs one,
the same gap #283's `focus output` shipped with. The argv is sway's documented
surface, both shapes are pinned by tests (this file uses `output <name> <verb>`
AND `focus output <name>`, so getting one backwards is the live risk), and the
read-back turns a wrong guess into a warning naming the outputs rather than a
screen that silently stays dark.

Six new unit tests cover the group-aware filter on both backends, the headless
no-op case, both disable spellings and the marker set. 246 pass on Linux.
2026-08-18 17:49:45 +02:00
enricobuehler f24eb02692 fix(packaging): the DualSense driver-priority guard has to be 0, and cover the capture node
The shipped WirePlumber policy sets `priority.driver = 1` on a DS5's ALSA sink and says it
"keeps the pad from ever driving the graph". Read against PipeWire's own recalc, it does not:
`priority_driver` is unsigned and `pw_context_recalc_graph` skips a driver only when it is
`<= 0`. At 1 the pad is merely LAST in the ordering — and last is still elected whenever
nothing above it qualifies, which on a punktfunk host is the ordinary in-session state,
because claiming our own sink as the default output leaves the box's real card idle. With
`node.always-process` on the same node it is also permanently runnable, i.e. permanently
eligible.

Zero is the value that means excluded. The pad keeps driving the streams actually linked to
it — a driver always drives its own group, priority orders the election and nothing else —
so GE-Proton's haptics are unaffected.

The second rule covers the capture side of the same cards. That node is what clocked a
reporter's desktop audio for a whole session: in the Pro Audio profile it carries
`priority.driver = 2600`, never suspends, and had nothing linked to it at all — its only
function on that machine was to clock other people's graphs. The `alsa_output` matches never
touched it. Only the priority is set there; holding a device open is about the playback node
GE opens raw, and an always-processing microphone is not something this host should ask for.

Both of these are belt to the braces of the host-side fix — a capture group that carries its
own driver cannot be handed one — but they are worth having on their own: they are what stops
a pad from clocking anything else on the box, including a build that predates it.
2026-08-18 17:47:00 +02:00
enricobuehler e053292a80 docs: the host's own audio output, and what to do when another device clocks it
`PUNKTFUNK_STREAM_SINK` has existed since 0.29 and appeared in no documentation at all, which
was tolerable while it had two values and one of them was "don't". It has three now, the
default changed shape, and the change is visible to anyone who opens their audio settings
mid-session: there is a virtual output *and* a recording stream named `punktfunk-audio-…`,
and someone will want to know whether that is a leak. It is not.

Troubleshooting gains the section the 2026-08-14 investigation should have been able to
start from: audio broken up while video is steady, one WARN line naming the node that took
our clock, what that means for the sound (its stalls are now our holes), and the two ways
out — remove the loopback, or turn off the profile of a sound card that is reached over the
network and cannot be clocked at all.
2026-08-18 17:46:38 +02:00
enricobuehler ed075b98dd feat(host/audio): the capture line says which node is clocking it
On 2026-08-14 a reporter's audio stuttered. Answering "what is clocking desktop audio on
that box?" took four field logs, a purpose-built probe script, and finally a pw-top DRIVER
column — and the answer was a DualSense's sound card, attached over the network, that
nothing was linked to.

Every part of that answer was already in the daemon, one bind away. `node.driver-id` on our
own node names the driver of the group we are scheduled in; it is deliberately not in the
registry's announce set, so it needs the node bound and its `info` event read, and the
daemon republishes the props whenever the graph is recalculated.

So bind our node, keep a map of node id to name from the registry, and log the driver by
name whenever it changes. In null-sink mode there is exactly one right answer — our own sink
— so anything else is a WARN that names the culprit and says what it means: the holes in
this stream are that node's scheduling, not ours. The legacy topologies have no driver of
their own and borrow one by design, so there the same fact is an INFO that names it without
judging it.

On change rather than per window, deliberately: the driver moves a handful of times in a
session, and the 30 s capture summary is written from the RT callback while this arrives on
the main loop — a shared field would need cross-thread state for a value that a line of its
own carries better (`grep 'graph driver'`).
2026-08-18 17:43:28 +02:00
enricobuehler ec44079db4 fix(host/audio): the Linux capture sink drives its own graph group
Our desktop-audio sink was a pw_stream wearing media.class=Audio/Sink. A stream is
structurally a follower — it never drives — so the group it forms with the game's output
streams has no driver of its own, and PipeWire's recalc assigns every such group to the
highest-priority *running* driver anywhere on the box.

On a reporter's host that was the DualSense forwarded over VirtualHere: its USB-Audio card
sat in the Pro Audio profile (priority.driver=2600, never suspends), nothing was linked to
it, and it clocked our capture and the game's four streams for a whole 15-minute session.
Its frame counter is a kernel stub — vhci_get_frame_number() logs "not yet implemented" and
returns 0, ~1900 times a second — so snd-usb-audio could not recover a device clock for an
async endpoint arriving over the network. Not xruns: every cycle that happened was healthy
(ERR 11 in 15 min, WAIT never past 111 µs); the loss was in the interval *between* cycles.
3.9 delivery holes a second, worst 142 ms, and 15.4 % of the audio that user heard was
silence this host synthesized over the gaps.

So mint a real sink instead: a support.null-audio-sink adapter, created on our own
connection, captured through its monitor. That node IS a driver — the null sink publishes
node.driver=true and the audio adapter forwards its props — with a timerfd inside the
daemon's realtime data loop, so the group carries its own clock and no hardware (or
network-attached) device can be elected for it. It is the same object pactl load-module
module-null-sink creates, which is the most exercised virtual-sink path on Linux.

Three properties carry the design, and each is a decision rather than a default:

  * node.passive on the monitor tap. A link is passive when the input port is passive and
    the output node can suspend (any Audio/Sink), and a passive link makes neither end
    runnable by itself — so between sessions the group is idle and the null sink's timer
    parks with it. That answers, by construction, the "200 callbacks a second forever on a
    parked host" objection that kept node.always-process off the old stream sink. While a
    game plays, its own non-passive link makes the sink runnable and run_nodes() walks that
    through the monitor to us, so pause/resume accounting keeps today's meaning exactly.

  * node.force-quantum, not node.latency. A driver's quantum is the smallest node.latency
    among its followers, clamped — and then rounded DOWN to a power of two, because
    default.clock.power-of-two-quantum is true by default. That is why our 240-frame ask has
    silently been served as 128 on every stock Linux host since the capture was written: the
    callback runs at 2.67 ms, not the 5 ms it is designed around. force-quantum skips the
    rounding, and because this sink drives only its own group it forces nothing on anyone
    else's device — which is exactly why the same key would have been the wrong answer while
    we were borrowing somebody's hardware clock.

  * node.dont-fallback WITH node.linger, never one alone. WirePlumber 0.5 reads
    dont-fallback on its own as licence to destroy the stream the moment its target is not
    visible ("defined target not found"); linger turns that into "wait for it". Together
    they mean the tap can only ever record our sink — never, not even for the moment before
    ours registers, a hardware sink's monitor.

PUNKTFUNK_STREAM_SINK grows a third value: unset is the new topology, =stream is the 0.30
one (a one-release escape hatch, so a field A/B needs no build), =0 is still the legacy
default-sink-monitor follower. The routing claim, the capture callback, the stats line and
everything downstream are untouched — the sink node changed owner, not the pipeline.

The channel map now has one source and two views (pod and audio.position string), because a
created node is configured by properties while a stream is configured by a format pod, and a
map that disagreed with itself between them would swap channels silently.

Gated in punktfunk-rust-ci amd64: fmt, clippy --all-targets -D warnings (non-vacuous —
"Checking punktfunk-host" present), 607 tests. On-glass validation is still owed and is what
the plan gates the merge on: pw-top must show our sink at the top of its own group with the
game's streams and our tap under it, and 5 min of loud audio at delivered_pct=100 gaps=0 on
a box where a hardware sink is also running.
2026-08-18 17:40:35 +02:00
enricobuehler b81aee6821 Merge pull request 'Audio follow-ups: the jitter ring deepens by inserting instead of de-priming, client audio threads get real priority, fs-change syncs are rate-capped' (#303) from worktree-audio-followups into main
Reviewed-on: unom/punktfunk#303
2026-08-18 15:05:19 +00:00
enricobuehler 412991f6a3 Merge pull request 'DualSense haptics + speaker work in-game: four fixes between the game and the pad' (#302) from worktree-pad-audio-fidelity into main
Reviewed-on: unom/punktfunk#302
2026-08-18 15:02:16 +00:00
enricobuehler e43d67c721 Merge pull request 'The Android client adopts the Skia console — one gamepad UI on three platforms, the Compose mirror deleted' (#301) from worktree-android-skia-console into main
Reviewed-on: unom/punktfunk#301
2026-08-18 15:01:49 +00:00
enricobuehler ba227057b6 build(android): the Skia archives are served from our own Gitea release, by default
The WP6 hosting step lands where the project's git assets live: a public release on
git.unom.io (R2-backed) rather than the sccache bucket — unom/skia-binaries, tag 0.99.0,
mirroring rust-skia's own release layout so skia-bindings' {tag}/{key} url template fits
it verbatim:

  https://git.unom.io/unom/skia-binaries/releases/download/{tag}/skia-binaries-{key}.tar.gz

The release carries the armv7-linux-androideabi archive we build (rust-skia publishes
none) and byte-for-byte mirrors of the aarch64/x86_64 assets, each with its sha256 and
provenance in the release notes.

kit/build.gradle.kts now bakes that template in as the DEFAULT for every cargo-ndk run —
dev boxes and CI need no configuration, GitHub is out of the Android build path entirely,
and `-PskiaBinariesUrl` / the SKIA_BINARIES_URL env (and CI's repo variable) remain as
overrides for staging the next skia-safe bump's archives before they are released.

Proven: unauthenticated downloads of all three archives byte-match the local sha256s;
a clean-target cargo-ndk armv7 check installs from the release (`DOWNLOAD AND INSTALL
SUCCEEDED`); assembleDebug with the skia-bindings build dirs wiped re-fetched all three
ABIs from the default and built green.
2026-08-18 16:52:10 +02:00
enricobuehler 428fef2b20 refactor(android)!: the Compose console is deleted — the Skia shell is the console
WP7 of design/android-skia-console-port.md, unlocked by WP6 (every ABI carries the native
host) and the on-glass pass. The third gamepad-UI implementation is gone: Android's console
is now the same pf-console-ui shell Windows and Linux show, and the Compose mirror that
re-implemented it screen by screen is deleted rather than parked.

Deleted (~6.5 kLOC): GamepadHome, GamepadSettingsScreen, GamepadAddHostScreen,
GamepadDialogs, HomeTiles, the console halves of LibraryScreen (coverflow, ConsoleLibrary),
ConnectOverlay (ConnectTakeover), ConnectScreen / ConnectPrompts / AdaptiveDialogs
(gamepadUi branches, console options dialog), App.kt's GamepadShell + GamepadScreen — and
their tests (HomeTilesTest, GamepadSettingsLayout/RowsTest, ConsoleSubScreenRoutes/RowsTest,
the buildSettingsRows pins in GamepadPaletteTest, the Compose-console shot scenes; the tab
vocabulary is pinned by the Rust twin against the same shared vectors file).

Kept, deliberately: GamepadChrome/Nav/Aurora/Ink/Palette — the Compose platform screens the
console still opens (Controllers, Licences per D7), MainActivity's touch-UI pad navigation,
and the settings palette preview all draw with them; LibraryPosition + the TOUCH library
(the touch shell's own pushed screen); GamepadUi.kt (the mode switch).

App.kt folds the native host's presence into `gamepadUi` itself: no host, no console —
a controller then drives the touch UI through Compose focus (nothing left to fall back to).
The sysprop becomes a triage switch (`debug.punktfunk.console_backend=none` forces the
touch UI on glass; the `compose` value is meaningless now).

Marked breaking for the store-screenshot surface: the Compose console's marketing scenes
(console home, console settings, coverflow, connect takeover) are gone — the Skia shell
renders over native GL and cannot compose under Roborazzi. Its shots come from the desktop
screenshot dump (the same pixels by construction) or a device capture.

Gates: :app:testDebugUnitTest + :kit:testDebugUnitTest green, installDebug on the NP3 and
the console renders on glass (carousel, OS marks, legend, aurora — the desktop look).
2026-08-18 16:43:06 +02:00
enricobuehler 0a6a49a9aa feat(packaging): a WirePlumber policy holds a DualSense's sound card for GE-Proton
GE-Proton's DS5 haptic router opens the pad sink's backing hw: device RAW whenever it is
free — then its own path re-probe EBUSYs against its own handle, invalidates the stream,
and spins a 100 Hz "device generation" refresh loop: haptics dead, speaker dead, and in
one game a buffer race in the same machinery crashed the title outright. On SteamOS,
where that code was developed, PipeWire always holds the device, so GE lands on its
well-tested Pulse-routing fallback immediately and none of this fires.

Ship the SteamOS-shaped environment: node.always-process + no suspend keeps PipeWire
holding the device from the moment the card appears, and priority.driver = 1 keeps the
pad — whose USB audio clock (virtual or physical) is nobody's idea of a house clock —
from ever driving the graph. Installed by rpm/deb/arch/nix into
/usr/share/wireplumber/wireplumber.conf.d/. Matches both DS5 product-string spellings;
covers physically plugged pads on a headless host identically.
2026-08-18 16:42:33 +02:00
enricobuehler e356e354f2 fix(pad-audio): the speaker lane gets the music coder, the haptics lane keeps LowDelay
Both 0xD1 lanes encoded with Application::LowDelay at 64 kbps CBR — right for voice-coil
rumble (felt latency, band-limited content), audibly wrong for the pad speaker, which
carries real programme audio: on glass it "sounded insanely compressed". The speaker lane
now uses the full Application::Audio coder at 96 kbps (~120 bytes per 10 ms frame, still
far under one MTU); its few ms of extra algorithmic delay are inaudible on a speaker.
Haptics unchanged.
2026-08-18 16:42:31 +02:00
enricobuehler f737414949 fix(usbip): pace ISO completions against an absolute deadline, not relative sleeps
The simulator slept `service_interval × packets` per URB, measured from "now" at handling
time — so tokio timer slop, socket I/O and handler lock waits all added ON TOP of the
nominal period, every URB. The virtual pad's audio clock ran measurably slow under load
(~26 %: hw_ptr advanced ~35.7k frames/s against a 48 kHz stream), the PCM backed up into
xruns, and — because snd-usb-audio's clock IS URB completion — anything clocked off the
device dragged with it. On the test box the pad sink became the PipeWire graph driver and
pulled the desktop capture down to 50 % delivery ("insanely distorted" stream audio).

Pace against a per-endpoint absolute deadline ledger instead: each URB advances the
endpoint's deadline by exactly its nominal duration and sleeps until that instant, so
overhead eats into the next sleep rather than accumulating. A stall beyond 20 ms
re-anchors to now instead of fast-forwarding a burst. Measured after: 48005 frames/s.
Two paused-clock tests pin the exact rate and the re-anchor.
2026-08-18 16:42:29 +02:00
enricobuehler dff2769ba9 fix(pad): the usbip capture forwarded the pad's hardware quad as the wire's speaker pair
The isochronous endpoint carries the DualSense's own channel map — ch0 = headphone LEFT,
ch1 = headphone RIGHT and the built-in mono speaker, ch2/3 = the voice coils — because
everything a game writes has already been folded to hardware channels by the host's UCM
split (or written there directly) before it reaches the endpoint. The 0xD1 wire contract
instead puts the *speaker pair* on ch0/1. Forwarding the quad verbatim shipped headphone-
left (silence) as wire speaker-left and the actual speaker as wire speaker-right, which
the client renders onto the one split-sink channel that current PipeWire never wires to
the physical speaker. Field-diagnosed on glass end-to-end: haptics felt, speaker dead,
a test tone measured on exactly one channel at each hop.

Duplicate the hardware speaker channel across the wire's speaker pair; pass the coils
through; drop headphone-left deliberately (a remote pad's jack is not a wire surface).
The stream-sink capture path (uhid pads) already emits the logical layout and is
unchanged.
2026-08-18 16:42:27 +02:00
enricobuehler f3a69d5b56 build(android): the 32-bit ABI gets the Skia console too, from a self-hosted archive
WP6 of design/android-skia-console-port.md. rust-skia publishes prebuilt Skia archives for
aarch64/x86_64/i686-linux-android but not armv7-linux-androideabi, and the 32-bit TV boxes
(Chromecast-with-Google-TV class) are console-UI-always devices — so the archive is built once
with skia-bindings itself (FORCE_SKIA_BUILD=1 for pf-console-ui's gl+textlayout feature set:
1666 ninja steps, ~80 s on an M-series Mac) and packed in the exact skia-binaries/ layout the
downloader unpacks. Key a25a0fdb7d90429aa2d1-armv7-linux-androideabi-gl-jpegd-jpege-pdf-textlayout,
sha256 4867856b… (table in kit/build.gradle.kts beside the two GitHub keys).

- clients/android/native: the console host and its deps are on every Android ABI now
  (the arch gate is gone; nativeConsoleAvailable() answers true on all three).
- kit/build.gradle.kts: forwards SKIA_BINARIES_URL (-PskiaBinariesUrl or the env) to every
  cargo-ndk run — the {tag}/{key} template that serves all three keys from one mirror.
- .gitea/workflows/android.yml: the repo variable SKIA_BINARIES_URL feeds it.

Proven: on a clean target dir with the url template pointing at a local mirror, every ABI's
skia-bindings log says DOWNLOAD AND INSTALL SUCCEEDED (armv7 included), the full debug APK
builds and installs, and cargo ndk clippy -D warnings is green on all three ABIs.

⚠ Until the three archives are hosted (storage.unom, then set the CI variable), an armv7 build
without the url falls back to a Skia source build — minutes with ninja present, a red leg
without it. Hosting is the one step left in this WP.
2026-08-18 16:28:02 +02:00
enricobuehler 6d1c7187d3 feat(android): the Skia console fronts the gamepad UI, behind a transition switch
WP4 + WP5 of design/android-skia-console-port.md. The Android client now hosts the same
console shell the Linux/Windows session binary shows — drawn by native over EGL/GLES on a
SurfaceView (the WP3 host) — and Kotlin keeps every service behind it.

Kotlin (clients/android/app/.../console/):
- SkiaConsole — the process-lifetime holder (the console's stack survives a stream, exactly
  as the desktop's does): host rows from KnownHostStore + discovery + the reachability sweep,
  the library pipeline (cache first, wake + retry across the boot window, catalog, running
  set, posters over mTLS as bytes), pairing via nativePair, the wake-and-wait loop, the
  settings round-trip (Settings ↔ trust::Settings JSON; console-owned keys persisted whole),
  OverlayAction::Launch → connectToHost with the host's profile / a pinned card's one-off /
  the request-access budget, CancelConnect, Quit → task to back, CopyText → clipboard, and
  punktfunk:// links (known-and-pinned dials; anything needing trust is a notice — a link
  never establishes trust).
- SkiaConsoleShell — the composable: the SurfaceView + its lifecycle, safe-area insets
  (systemBars ∪ displayCutout, in surface px), a density floor under the couch scale for
  phones, MainActivity's pad probes feeding raw MenuSamples (buttons, stick, HAT) into the
  shared MenuNav, remote D-pad keys as discrete events, hardware keys as Keys + typed text,
  the system Back as B, touch as pointer input, haptic pulses, and the two Compose overlays
  the console can open (Controllers, Licences).
- ConsoleJson — the wire, in the model types' own serde shapes.
- App.kt picks SkiaConsoleShell over GamepadShell when SkiaConsole.wanted(): the native host
  exists (64-bit ABIs) and sysprop debug.punktfunk.console_backend != compose. The Compose
  console stays until the on-glass matrix and the armv7 archive land (WP7).
- NativeBridge: the nativeConsole* externs. GameEntry.platform parsed + cached (Collections).

pf-console-ui (WP5): the Android-only settings rows over Settings::extra `android.*` keys
(low-latency decode, phone rumble/gyro, SC2 passthrough, DualSense capture, controller-UI
mode) and two platform-screen action rows (Controllers, Licences) that raise the new
ConsoleCmd::OpenPlatformScreen; row_on() keeps them off the desktop's list, which shows
exactly the rows it did (pinned by test). "Send logs" hides where nothing serves it.

pf-console-ui (test harness): a test-only fixed-step clock for the screenshot dump. The
dump's aurora phase was wall-clock time, so two runs agreed only at equal machine load and
speed — a pixel comparison of main against a candidate read as a regression that was pure
drift. With the fixed step the same tree renders the same pixels anywhere.

Gates: container fmt + clippy -D warnings + build + tests; cargo ndk clippy -D warnings on
arm64-v8a / x86_64 / armeabi-v7a; :app:compileDebugKotlin; the fixed-clock dump of this
tree vs main.
2026-08-18 15:55:01 +02:00
enricobuehler 84db02ef6d refactor(console-ui): the Skia console shell becomes portable, and Android grows a GL host for it
The shell that pf-console-ui draws for the Vulkan session binary is the console UI
the Android client should have been showing all along (design/android-skia-console-port.md).
This is WP1–WP3 of that plan: nothing the desktop does changes, and the same crate now
compiles for aarch64/x86_64-linux-android against rust-skia's prebuilt GL archives.

pf-client-core: the data the shell speaks is un-gated for android — trust::Settings and
the known-hosts store, the profiles model, deep links, the library MODEL (the ureq
fetches stay desktop), and three modules split out so the platform-bound ones can stay
platform-bound: audio_format (the AUDIO_FORMAT_* table, re-exported by session),
decoder_pref (migrate_decoder_pref, re-exported by video), menu_nav (MenuEvent /
MenuNav / PadInfo, re-exported by gamepad) and console (OverlayAction, PointerInput,
SessionPhase, re-exported by pf-presenter — pf-console-ui sits above pf-presenter, so the
types could not live there). MenuNav gains the stick hysteresis both the Apple and the
Android console had to grow on glass: an engaged direction holds until its own axis
falls below MENU_RELEASE (0.3), so a diagonal flick is one move, not two.

pf-console-ui: the Vulkan overlay + SDL event path move behind the default
`vulkan-overlay` feature (clients/session is unchanged); a `Key` enum replaces SDL
scancodes; a `SettingsStore` seam replaces the six direct settings-file sites (desktop =
the file, exactly as before; SnapshotStore for hosts across a language boundary);
`Viewport{width,height,insets,scale}` replaces bare w×h (zero insets + the couch formula
on the desktop = byte-identical layout); `Platform` filters the settings rows (the
desktop shows every row it did); ConsoleOptions carries the store, the platform and the
GPU cache budget; the portable `Console` driver is what both hosts hold. skia-safe's
features are target-specific so the desktop key stays jpegd-jpege-pdf-textlayout-vulkan
(the flatpak pin) and Android resolves gl-jpegd-jpege-pdf-textlayout. The model types
derive serde: the wire IS the model.

clients/android/native: src/console/ — a hand-declared EGL binding, Skia's GL
DirectContext over FBO 0, one render thread paced by eglSwapBuffers that owns the
console and applies Kotlin's input from a command queue, and ~28 nativeConsole* JNI
seams (surface lifecycle, pad samples through the shared MenuNav, pointer, keys, text,
session phases, model pushers, a blocking event poll, the command-bus drain). Gated to
the 64-bit ABIs: rust-skia publishes no armv7 archive and skia-bindings would silently
build Skia from source; nativeConsoleAvailable() tells Kotlin which.

Gates: container fmt + clippy -D warnings (all targets) + build + tests green
(pf-console-ui 180, pf-client-core 221); cargo ndk clippy -D warnings for
pf-client-core / pf-console-ui / punktfunk-client-android on arm64-v8a, x86_64 and
armeabi-v7a; skia-bindings logged DOWNLOAD AND INSTALL SUCCEEDED for both 64-bit keys.
2026-08-18 14:40:56 +02:00
enricobuehler 20f766799c feat(plugin-kit): cap fs-change syncs to one per interval — a debounce cannot bound a launcher that never stops writing
The Skynet field log carried `plugin:steam sync (fs-change): reconciled
16 entries` 102 times in 27 minutes: Steam writes to its dirs the whole
time a game runs, and every write re-walked the library. The 3 s
`Stream.debounce` collapses a BURST, but a debounce extends on every
event and so cannot bound the RATE under sustained churn.

`SyncSettings.minInterval` (default `DEFAULT_FS_CHANGE_MIN_INTERVAL` =
30 s; `LibraryPluginDef.minInterval` to override) sits on top of the
debounce: debounced events land in a sliding queue of one, and a drain
loop syncs, then holds for the interval. Changes inside the hold coalesce
into exactly one trailing sync, so nothing is lost and a launcher in a
writing frenzy costs one re-walk per interval instead of one per quiet
gap. Optional on `SyncSettings`, so plugins built against the older kit
keep compiling.

Test drives real `fs.watch` on a temp dir: 28 writes at 25 ms clear a 20 ms
debounce every time and would be ~28 syncs; with a 400 ms interval they
are three, at +32 / +433 / +835 ms.

Not here: narrowing the Steam plugin's watch set (`steamapps/` +
`libraryfolders.vdf`, not `userdata/`/`logs/`) lives in the steam plugin
repo; and the host-side reconcile coalescing during a session is optional
— the kit cap is the lever.
2026-08-18 14:15:41 +02:00
enricobuehler 62119e553a feat(client): give the audio decode, pad-audio and WASAPI threads real priority — the Realtime portal in a flatpak, rtkit outside, MMCSS on Windows
The device callbacks already run where the OS puts realtime audio (the
PipeWire playback callback on the graph's data loop since #292, WASAPI's
event-driven loop woken by the engine). The threads that FEED them were
plain: the decode leg (`punktfunk-audio-rx`), the pad-audio renderer and
its PipeWire loop, the Linux mic loop, and on Windows the render and mic
loops themselves. On a Steam Deck the same four cores decode 1440p120 and
present it, and a decode thread descheduled past the ring depth is a
drought the callback then has to conceal. #292's `setpriority(-10)` was
a no-op there: the user's RLIMIT_NICE is 0.

New `audio_rt` module, one call at the top of each of those threads:

- Linux: `setpriority` where RLIMIT_NICE allows; else, INSIDE A FLATPAK,
  the xdg-desktop-portal Realtime portal (`org.freedesktop.portal.Realtime`
  on the session bus); else rtkit (`MakeThreadHighPriorityWithPID` on the
  system bus). The split is PipeWire `module-rt`'s, and it is not
  optional: verified on the Deck (rtkit 0.14) that rtkit-daemon has NO
  PID-namespace translation — it looks up `/proc/<pid>/task/<tid>/stat`
  with the numbers it is given — so a direct call from a sandbox is
  answered with ENOENT (and there is no `busctl` in the GNOME 50 runtime
  either, which rules out the shell-out). The portal maps the sandboxed
  pid/tid and calls rtkit on the app's behalf, and portals need no
  `--talk-name`. polkit gates both with the TARGET process as subject:
  verified on the Deck by renicing a live active-session thread and a
  `steam` user-service thread (what a Steam-launched client is) through
  both rungs, and restoring them; only remote (ssh) sessions are refused.
  Via zbus, exactly as `pf_frame::thread_qos` does on the host (same
  version, features and backend — one lock line). Never setcap/SCHED_RR:
  the cap_sys_nice route is the one that killed KDE sessions in the field.
- Windows: MMCSS "Pro Audio" + THREAD_PRIORITY_HIGHEST, raw `extern
  "system"` declarations as `pf_frame::session_tuning` spells them; the
  MMCSS handle is leaked (thread-lifetime, the OS reverts it).

Every rung is best-effort and logs at debug what it got (`audio thread
priority raised via=…` / `refused why=…`); a refusal leaves the thread
exactly as it was. Acceptance on the Deck: `ps -eLo cls,rtprio,ni,comm |
grep punktfunk` shows the decode thread at nice −10 after connect.
2026-08-18 14:15:00 +02:00
enricobuehler 5aebb1ace4 fix(client/apple): port the jitter ring's insert — a sync request for more depth deepens the ring instead of de-priming it
The Swift mirror of core's `JitterPolicy` change, line for line: `underRun`
and `insertOneFrame()` next to `overRun` and `shedOneFrame()`, `hollow`
judged against the ADAPTIVE target (`adaptiveTarget(lift:)`, never the
sync-inflated one), `syncWantsMore` arming the insert, and the same
constants (`insertSustainMS = shedSustainMS`, `insertMarginMS =
AvSync.deadbandMS / 2`). `Stats` gains `inserts` and the 1 Hz `audio:`
line logs `drift_inserts=` next to `drift_sheds=`.

`insertFront` is index-based where core's is a `VecDeque`: the copy lands
in the slots just before `readIdx` (free exactly when the ring has that
much spare capacity) and `readIdx` steps back over it; both offsets are
shifted forward by one capacity first when `readIdx` is too small, so
neither can go negative into `%`.

`dropFront` gets the same seam fix as core's `crossfade_drop`: the
fade-out source is now the head of the discarded region — the
continuation of the sample just played — not its tail. Driven through the
hard-cap trim, the old fade stepped by 2 688 samples on a ramp where the
new one stays under 17.

`AudioRingDriftTests` carries the same vectors as core (`…NeverDeprimes`,
`…DeepensWithoutADeprimeOnACleanLink`, the insert seam heard end to end,
the drop seam against the sample played before it); both regression tests
were run against the backed-out fixes and fail there. 42/42 on macOS.
2026-08-18 14:00:52 +02:00
enricobuehler d5462d6d3d feat(client): apply the jitter policy's insert on the PipeWire, WASAPI and AAudio rings, and log it as drift_inserts
The three `VecDeque<f32>` rings apply `JitterStep::insert_front` with
`crossfade_insert` right where they apply `drop_front` today. The PipeWire
callback runs on the graph's realtime loop; the insert stays inside the
ring's reserve (hard cap plus 64 frames) because the policy only inserts
below its target. Same on AAudio (`hard_cap_max + RING_CHUNKS × frame`).

Concealment must stay visible in both directions: `PlaybackVitals` gains
`inserts`, the 10 s `audio playback` line gains `drift_inserts=` next to
`drift_sheds=`, and the Android 10 s line gets the same field. On glass
the tell that this works is `underruns` no longer stepping in lockstep
with `av_offset` swings while `drift_inserts` climbs instead.
2026-08-18 13:52:44 +02:00
enricobuehler 8cff5bda6b fix(core/audio): the jitter ring could only get deeper by de-priming, so a sync request for more depth was a silence gap
`JitterPolicy` could lower its depth gently — one crossfaded frame per
sustain window — but could only RAISE it by de-priming: a full
`target − depth` of inserted silence plus the priming wait. `hollow` was
judged against the sync-inflated effective target, so the moment the A/V
sync loop asked for ≥ 10 ms more depth (audio early against a picture
whose latency wandered — a 53–74 fps KWin source, an ABR retarget, a
keyframe burst) the ring read as hollow on the very next callback, and
the next single late packet cost a 15–60 ms gap. Every client runs this
policy; it is the "started at 0.24/0.25" shape.

Now the ring moves toward its target in BOTH directions with the same
instrument:

- `JitterStep::insert_front` mirrors `drop_front`: when the sync loop
  wants more than the adaptive target and the depth EWMA has sat more
  than `INSERT_MARGIN_MS` below the request for `INSERT_SUSTAIN_MS` of
  consumed audio, duplicate ONE frame at the front, crossfaded. Sync-only
  (an un-wired ring is bit-identical to before), primed-only, and
  below-target-only, so it can never fight the trim.
- `hollow` is judged against the ADAPTIVE target — the one underrun
  evidence proved — never the sync request. Growth that was never banked
  still re-primes on the click it already paid; sync never de-primes.
- `crossfade_insert` is the RT-safe twin of `crossfade_drop` (push_front
  inside reserved capacity, seam blended in place).

The margin is HALF the sync loop's ±10 ms deadband, not the shed's
`shed_excess_ms`: the loop only speaks once the offset has left the
deadband, so a margin at or above it would leave every request it is
allowed to make permanently unanswered.

Also fixes `crossfade_drop`'s seam, found while mirroring it: the
fade-out source was the LAST `fade` discarded samples, which is adjacent
to the survivors — but the sample the device had just played was
adjacent to `ring[0]`, so the seam still opened with a step of
`drop − fade` samples of waveform (3 ms of a 5 ms shed). The old test
only bounded steps INSIDE the faded region. Both helpers now fade out
from the continuation of what was just played, and the tests check the
seam against that sample.

Simulated (the bunching harness now applies inserts and counts
re-primes): a clean link asked for +20 ms deepens in 6 s with 3 inserts,
zero audible, zero re-primes; a −50 ppm host clock over ten minutes is
absorbed by 9 inserts instead of a re-anchor click; the bunching link
asked for +25 ms lands at 4 audible / 0 in the tail / 1 re-prime where
the unsynced run pays 7 / 2 / 4. The two existing convergence tests keep
their bounds. No new `pub const` (cbindgen); the C header is unchanged.
2026-08-18 13:50:36 +02:00
enricobuehler 4ce7a3c11f Merge pull request 'The Apple client catches up with the console-UI overhaul: grid, sort, Collections, the v2 motion contract' (#293) from worktree-apple-console-parity-sweep into main
Reviewed-on: unom/punktfunk#293
2026-08-18 11:31:21 +00:00
enricobuehler a7eadbb680 fix(clients/apple): the keyboard tray — a flight that lands, hardware typing, and Esc that means Done
- The seated row's flight went sideways off-screen while fading: two live matched views with the
  source flag swapped sent the INVISIBLE list row flying, and the seated row merely appeared. Now
  exactly one matched view per id at any time — the list row hands its frame to an invisible
  provider that unmounts when editing starts, so the inserted seated row animates in from it (and
  its removal flies back). Verified frame by frame on the Mac.
- A hardware keyboard types straight into the field while the tray is up (an iPad on a Magic
  Keyboard, a Mac): characters insert (the field's own `allowed` set is the rule — a capital or
  an umlaut the on-screen grid doesn't offer is fine), ⌫ deletes, arrows drive the key cursor,
  Return and Esc are Done, ⌘-anything is left to the system. No text field, so no system keyboard
  is ever raised. State writes are deferred off the key-event dispatch — written synchronously
  they re-rendered the screen mid-delivery and a fast burst lost every other keystroke.
- Esc while typing used to close the whole screen (the screen's cancel shortcut fired first);
  that shortcut is inactive while the tray is up, and Esc is the tray's Done.
2026-08-18 13:14:34 +02:00
enricobuehler f5a5d19a3d fix(clients/apple): the grid fills the field's width, and the keyboard's field comes down to the keys
Grid: the columns now stretch to fill the safe-area width (whatever the last cell would have left
over is shared out, cells keep 2:3) instead of stopping short of the right edge — on a phone the
slack was a fifth of the width — and the k floor goes back to the Deck's 0.75, so the covers stay
poster-sized and the field scrolls; the heading band gets a floor that clears the focused cell's
ring at every k.

Keyboard: the on-screen keyboard tray covered whatever field rows happened to be under it. While
a row is being typed into it now flies from its place in the list to a seat directly above the
keys (matched geometry, the tray's spring) and back when the keyboard closes; its slot in the
list stays empty meanwhile so nothing reflows. Same in the pair ceremony. Shot scene
`08b-gamepad-addhost-typing` (`PUNKTFUNK_SHOT_EDITING=<field>`) renders it.
2026-08-18 12:59:39 +02:00
enricobuehler 97a94b0a55 Merge pull request 'Present the Android video through ASurfaceControl, on the panel's real clock' (#290) from worktree-android-asc-presenter into main 2026-08-18 10:52:21 +00:00
enricobuehler 92f953fa39 fix(clients/apple): the library in a phone's height — rendered in the iPhone simulator, then made to fit
The shelf's detail band loses its `STORE · PLATFORM` line (the cover's chip already says it,
and the line only made the band tight); the grid keeps it except in a landscape phone's height.
Compact height, from renders in the iPhone 16 Pro Max simulator: the grid's k floor drops from
0.75 (the Deck's) to 0.5 so a ~290-pt field holds two rows instead of one, with heading bands and
captions floored at legibility; the coverflow holds a little cover height back so the title has
air; the collection tile's deck shrinks to clear its label rail; the shoulder hint hides on any
phone (a Pro Max in landscape is regular WIDTH — the rule is height-aware now) and Collections
advertises `↑ Sort` in its place; a portrait phone's legend keeps A/Y/B (X and ▲ still work) and
the sort/view tray stacks its two groups when the row won't fit.
2026-08-18 12:49:20 +02:00
enricobuehler 071c4041e4 feat(client/android): tail the cadence loop's health on the ASC pf.present line
The ASurfaceControl backend's 1 Hz pf.present line carried no cadence health, so
smooth mode was unobservable. Add late-permille / jitterMs / cushionMs / reanchors
(from CadenceClock::health, only under the smoothness intent) + the FIFO qDepth,
mirroring the SurfaceView presenter. This is what let the on-glass verification
attribute smooth-mode stutter to the Wi-Fi arrival-jitter TAIL (late tracks jitter
spikes, identical at 60 and 120 Hz), not the panel rate or a code bug — latency
stays the correct default; cadence pacing needs a clean/wired link.
2026-08-18 12:45:03 +02:00
enricobuehler c7be3bb9d0 Merge pull request 'Audio stutter stack audit: the pacer carried its debt, the Deck callback was not realtime, and the client log ring evicted its own audio line' (#292) from worktree-audio-stutter-stack-audit into main
Reviewed-on: unom/punktfunk#292
2026-08-18 10:41:03 +00:00
enricobuehler a3872ed4df fix(clients/apple): the view/sort bar becomes a tray — down on ▲, gone otherwise
A fixed band above the field ate a third of a landscape phone and wrecked the shelf there. ▲ was
already the way to the bar, so the bar is now only there when it is wanted: it slides down over
the field on ▲ (or the legend's `Sort & view` cell, for a pointer) on the console's INDICATOR
spring, with the top tray blur, and back up on ▼/A/B — the field keeps every point of height it
has on every screen. Collections opens the same tray (sort only) on ▲.
2026-08-18 12:32:56 +02:00
enricobuehler 70da960a37 fix(clients/apple): the grid owns its scroll offset — one impulse per move, not two
On glass a single step down read as two impulses: the ScrollView settled, then moved again as
the lazy grid laid rows out under it. Every number in this field is fixed and known (cell,
gap, margin, heading band, label air, all × k), so the grid no longer asks a ScrollView + lazy
layout to guess: `GridGeometry` computes each row's place, `scrollY` is ONE sprung scalar on a
plain offset (`springs::FOCUS`), the viewport clips, and rows are culled by the same arithmetic
(one row of look-ahead across the whole travel) — the desktop console's own model. Moves reveal
the focused cell minimally (ring and pop included), row 0 goes all the way up, a restored cursor
is seated a third of the way down. A finger still drags the field on iOS; the Mac keeps keys,
pad and click (no trackpad wheel on the grid — a small, named trade against the phantom impulse).
2026-08-18 12:22:26 +02:00
enricobuehler 582759b3cb refactor(client/android): set the ASC layer rate once, not per transaction
The layer's fixed-source frame-rate vote persists in SurfaceFlinger across
transactions, so re-asserting it on every present was redundant; the 'so a
governor that decays the vote keeps seeing it' rationale was disproven on the NP3
(no app-side rate hint raises its render-range floor either way). Set it once at
layer config, and correct the now-stale comment in the presenter. Behaviour
unchanged; one fewer FFI call per frame.

Split from the previous commit, which staged only the Kotlin revert.
2026-08-18 12:15:34 +02:00
enricobuehler baf011f7a7 refactor(client/android): drop the ineffective refresh-rate pins from the 120 Hz chase
Investigating why the NP3 wouldn't hold 120 Hz added window-level frame-rate pins
that turned out to do nothing and carried side effects, so they come back out.

Measured conclusion (NP3, dumpsys DisplayModeDirector): NO app-side API raises the
panel's render-range FLOOR — it stays min=0 under preferredDisplayModeId,
preferredRefreshRate, the per-layer setFrameRate vote, and
frameRatePowerSavingsBalanced alike, so the LTPO governor runs "video" content at 60
for power and only the system Minimum-refresh-rate setting (or a touch boost) lifts
it. A native GPU game (PPSSPP) sits at 60 at its menu for the same reason. This is
an OEM limitation, not a client defect, and the pins were both useless here and a
latent battery/rate side effect elsewhere:

- MainActivity.setStreamDisplayMode: reverted to `preferredDisplayModeId` only (its
  comment had also come to claim, falsely, that preferredRefreshRate pins min==max).
- surface_control: the layer's fixed-source rate vote is set ONCE at layer config,
  not re-asserted every transaction — the layer rate persists in SurfaceFlinger, and
  the "re-assert so the governor keeps seeing it" rationale was disproven.

Kept, because they are real fixes independent of the refresh cap: the ASurfaceControl
backend, the view-sized layer geometry, and the seed-period present-grid targeting
(which is what lets the picture reach a true 120 whenever the panel IS at 120 —
verified on-glass with the panel floored). The panelMs HUD readout that diagnosed all
of this stays.

Verified: cargo ndk arm64 check + clippy + rustfmt clean; gradle build + install green.
2026-08-18 12:15:13 +02:00
enricobuehler 25b97b5092 fix(clients/apple): the grid's first on-glass pass — a stick that moved twice, rows over the chrome, and a menu for the title
Four things the iPad found in the grid arrangement (design/apple-console-ui-parity.md, PR-2/3):

- "Navigation jumps at random." `GamepadMenuInput` resolved the stick per 60 Hz sample and
  fired a move whenever the resolved DIRECTION changed — a single flick to the right passes
  through samples where |y| > |x| on the way out of the dead zone, so one gesture read as UP,
  then RIGHT. Invisible on the carousels (their vertical axis is inert or a menu), a random
  jump on any 2-D field. Now an engaged direction stays engaged until ITS OWN input releases.
- The grid drew over the pinned title and the legend's tray, and rows vanished while "still in
  view": `.scrollClipDisabled()`. Rows are clipped to the viewport again; the margins and the
  heading bands are what give the focused cell's pop and ring their room, and each cell carries an
  invisible halo inside its scroll target so a minimal scroll never shaves them.
- The "focused row rides at 34 %" seat cut the row above half away after ONE step down with the
  whole field in reach — a jump, not a chase. A move now scrolls only as far as it must; the 34 %
  seat is kept for the initial restore, and the top row scrolls all the way up so the heading
  shows.
- The legend gets the bottom tray blur every other console screen has (rows scroll under it).
- "Copy link" is no longer a face-button action on a gamepad UI: X opens the title's Options
  menu (`LibraryTitleOptionsView` — the desktop's per-title menu: Copy link · Cancel, with the
  host-options idiom's title band, explainer and `A Select · B Back`); the field recedes under it
  the way the launcher recedes under a shell layer. Screenshot scene `11e-library-options`; the
  library scenes can now take `PUNKTFUNK_FAKE_LIBRARY` and `PUNKTFUNK_SHOT_INTERACTIVE=1` so the
  grid can be driven from a Mac keyboard.
2026-08-18 12:02:34 +02:00
enricobuehler 849a991c3a feat(clients/apple): the touch library grid learns sort and group
The fifth slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-5): the
plain grid draws `LibraryCollation.collate(catalog, sort, groupBy)` — launchers lead, then one
section per group under the grouping, each in the chosen sort — instead of its two fixed
Launchers/Games sections, with a toolbar menu for both. The sort is the shared `librarySort`
key (the same one the console's bar writes); the grouping (`libraryGroupBy`, none/platform/
store) is this grid's own, because on the console grouping is a PLACE (Collections), not a
mode of the shelf, and sections are the touch analogue of that place. `LibraryGridNav` already
took sections, so the keyboard cursor needed no change; ungrouped and launcher-less, the grid
renders exactly as it always did.
2026-08-18 11:38:28 +02:00
enricobuehler aec6ccf887 feat(clients/apple): the shell's push/pop becomes the console's spring, and the contract is pinned
The fourth slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-4):

- `ConsoleContract.swift` (PunktfunkShared): `ConsoleMotion` — the vectors' `motion_spring`
  numbers (response 0.42, damping 0.88, slide 36, scales 0.985/0.96, reveal 0.4, interruptible)
  — and `GpSettingsTab`, moved out of the executable target so `ConsoleVectorsTests` can reach
  both. `testTabNamesMatchTheSharedVectors` (this client's trailing About is its own; the
  desktop's Input is `desktop_only`) and `testMotionMatchesTheSharedVectors` (the v2 block) join
  the palette test; the vectors' v1 `$deprecated` note now names Android as the last v1 reader.
- `GamepadShellMotion.screen` is `.spring(0.42, 0.88)` — the console's `springs::NAV` — instead
  of the 0.26 s ease-out-cubic; Reduce Motion crossfades on the desktop's `REDUCED_NAV`
  (0.22, 1.0) with no slide and no scale, instead of snapping.
- Interruptible: while a push is in flight no layer owns the controller, so a zero-size
  `MidPushBackCatcher` takes B alone and clears the trigger that raised the top screen — the same
  spring carries it back down, the way the desktop retargets its NAV spring to 0. A and the rest
  stay dropped until the spring has passed 0.85 of its travel (`inputOpensAfter`, 0.20 s), not
  until a tween has finished (0.28 s): the wall is gone, the double-tap protection stays.
- The library layer's place stack rides the same spring and the same reduce-motion crossfade.
2026-08-18 11:36:56 +02:00
enricobuehler af2f51e9cc feat(clients/apple): Collections — group by platform, walk them, open one
The third slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-3): the
user's flow verbatim, "group by platform → navigate the platforms → pick PS3 → see its games".

- `LibraryPlaceStack` (PunktfunkKit) — the desktop's screen stack for the library, as a value
  INSIDE the library layer rather than deeper `GamepadShell` screens (its screen enum is derived
  with depth ≤ 1 by construction; macOS shows the library as a sheet, tvOS as a cover; and the
  desktop's own property is that drilling never touches the model). shelf → Y → collections → A →
  filtered shelf → B → collections → B → shelf → B → dismiss; a Collections ROOT offers Y "All
  titles" as a drill-in; Y is refused with a pulse on any drilled shelf and its hint hidden. The
  flows are tests, not glass.
- `CollectionsHandover.decide` — "start in collections", decided ONCE per shelf: refused when off
  or drilled, loading/empty/error decide nothing, a CACHED catalog counts as ready, and the library
  must be worth browsing (≥ 2 non-launcher groups — a platform-less Steam library is one group, by
  design). The desktop's epoch rule has no counterpart: an Apple shelf's view owns its own fetch.
- `LibraryCollectionsView` — one tile per collated group in the home carousel's tile language
  (same size, glass, recede), with label, count, kind caption, and a DECK of up to three covers
  from the sort-first titles: front 118 tall, each slot back 7 % smaller, 18 pt right, 7 pt up,
  6° turned, drawn back-to-front over HARD contact plates. A launcher fans its brand mark and is
  never fetched; a one-title group never fakes a stack. L1/R1 step the sort WRAPPING (the shelf's
  bar clamps — the desktop draws the same distinction); the sort pills ride the same bar, VIEW
  hidden. `GamepadCarousel` gains `onShoulder` for it.
- `LibraryConsoleView` hosts the places: a filtered shelf is `filtered(games, sort, group)` over
  the same catalog, art loader and running map (nothing re-fetches on drill-in or back); pushes and
  pops move with the shell's own transition; the shelf legend gains `Y Collections` where it
  applies; the title reads `host · profile · collection` on both the pinned iOS title and the
  sheet/cover navigation title. Screenshot scene `11d-collections`.
2026-08-18 11:32:32 +02:00
enricobuehler a823bcf6ae fix(client/android): present on the panel's real vsync grid, and pin the refresh rate best-effort
Two frame-rate fixes on top of the ASurfaceControl backend, both found on-glass
(NP3, 2800x1260@120).

Present target: the presenter derived its desired-present-time grid from the panel
period it LEARNED from latch spacings — but the target produces the latch, so once
a couple of 60 Hz-spaced latches landed the learner flipped to 60 and then paced
every frame onto the 60 Hz grid, locking the panel there. A plain ASAP present did
not fix it either: applying two transactions close together let SurfaceFlinger
coalesce the pair onto one vsync and idle the next (also 60). The grid now uses the
mode-table SEED period (the honest panel maximum, fixed for the session) for its
spacing and the last real latch only for phase, so every frame claims its own
vsync. The latch-learned period is demoted to a HUD readout. Layer frame-rate vote
is now re-asserted on every transaction (FIXED_SOURCE) rather than once, so a
governor that decays a one-shot vote keeps seeing it.

Refresh-rate pin: alongside preferredDisplayModeId, the stream window now sets
preferredRefreshRate and clears frameRatePowerSavingsBalanced (API 34) — the
documented levers to hold an LTPO panel at the mode's rate. Restored on stream exit.

On-glass result: the picture reaches a clean 120 (panelMs 8.1, displays ~120, latch
p50 ~4.5, e2e p50 ~16 ms) but does NOT hold it on this device — Nothing OS's LTPO
governor keeps the app render-range floor at 0 in the DisplayModeDirector regardless
of every app-side API tried (mode id, preferredRefreshRate, power-savings flag, and
the per-layer Exact/FIXED_SOURCE vote), and decays "video" content to 60 after the
touch-boost window. That is a pre-existing OEM limitation that constrained the old
SurfaceView path too, not something the presenter can override; the hints stay as
best-effort for compliant panels. The latency + no-dropped-frames wins stand
regardless (e2e 30 -> ~18 ms, skipped 40-50/s -> 0).

Verified: cargo ndk arm64 check + clippy + rustfmt clean; installed and streamed on
the NP3.
2026-08-18 11:32:18 +02:00
enricobuehler 2541436e35 feat(clients/apple): the gamepad library gets a grid arrangement and a live view/sort bar
The second slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-2):

- `LibraryConsoleView` — the chrome the desktop console's library screen owns and both of its
  arrangements feed: the view/sort bar across the top, the field (coverflow or grid, one persisted
  setting apart), the detail band, the legend, the backdrop. `LibraryView` still owns the data.
  The bar's controls write the cross-client keys the Interface settings rows write, so the two
  surfaces can never disagree; both apply live and the focused TITLE survives a resort.
- `LibraryBarView` — `SORT` Default · A–Z · Platform · Store leading, `VIEW` Shelf · Grid trailing;
  focus is an accent wash over the pill row (not the band — the desktop's own trap). ▲ from the
  field hands the controller over; ◀▶ step the sort clamped, L1/R1 pick the arrangement, ▼/A/B
  hand it back, and the legend swaps with it. Pills are tappable, and focusable Buttons on tvOS.
- `LibraryGridView` — 2:3 cells 150×225 design units scaled by min(width, height)/800, columns
  2…8 as they fit, the launcher rows squarely above the game rows with the heading between, the
  focused row riding a third of the way down on a spring, ×1.06 pop + accent ring OUTSIDE the
  cover, `Resume` in the coverflow's corner, entrance fanning on |Δrow|+|Δcol|. Cells are
  focusable Buttons on tvOS (a Siri Remote is no extended gamepad).
- `LibraryGridCursor` (PunktfunkKit) — the port of `GridShape`/`grid_step`/`grid_col_hint`: one
  shape both renderer and cursor read; ◀▶ walk the row and refuse at its TRUE ends, ▲▼ change row
  carrying the remembered column, L1/R1 page three rows and land on the ends, navigation declines
  until a layout exists. The desktop's nine grid tests, by name.
- `PosterImage` decodes at the DRAWN size (`CGImageSourceCreateThumbnailAtIndex`, 2× the cell's
  edge in pixels): a grid multiplies resident posters, and the desktop's grid was a slideshow
  until it stopped decoding 600×900 capsules for 150-point cells. The coverflow shares it.
- The coverflow is now the strip and its heading only; its detail band, legend and backdrop moved
  up into the container. Screenshot scenes gain `11b-library-grid` and `11c-library-bar` (the bar
  FOCUSED — no shot ever showed the desktop's bar focused, which is how its mis-sized wash shipped).
2026-08-18 11:24:46 +02:00
enricobuehler f9b5673d78 fix(client): the vendored decoder's DPB chatter evicted the whole log ring in three seconds
The 2026-08-17 Steam Deck bundle read `… 2037456 older lines evicted from the ring …` followed by
3.5 s of `Retaining pic POC` / `Stored picture` / `find_short_term_ref_by_poc` lines: cros-codecs
DEBUG-logs its DPB bookkeeping a dozen lines per frame, the ring layer captured every target at
DEBUG, and 4 096 lines last three seconds at 120 fps. The 27-minute session — and the 10 s
`audio playback buffer_ms= underruns=` line three investigation rounds had been waiting for —
was gone. A field ring a healthy decoder can flush is worse than none: it looks like diagnostics
and carries none.

The ring layer now normalizes `log`-bridge events to their real target (the host's
`log_capture.rs` recipe) and drops DEBUG/TRACE from `cros_codecs`; its WARN+ still lands, and the
bridge's `log.*` bookkeeping fields no longer ride every line. Tested end to end through the
bridge. `Cargo.lock` gains the two direct deps (already in the graph).
2026-08-18 11:14:08 +02:00
enricobuehler 8effeb792a fix(client,audio): the playback callback ran on our own thread at nice 0, and the graph did not wait for it
The desktop client's PipeWire playback stream connected without `RT_PROCESS`, so `process()` ran
on the main-loop thread at ordinary priority: PipeWire's data loop signalled it, and if this thread
was not scheduled inside the 5 ms cycle the graph rendered silence for our node and moved on — an
underrun neither our counters nor the ring ever saw, because by the time we ran the ring was full
and the callback drained normally. On a Steam Deck decoding 1440p120 alongside, that is a real and
invisible source of clicks. The host's own PipeWire stream nodes have run `RT_PROCESS` since they
were written.

- The stream connects with `RT_PROCESS`; the ring is pre-reserved (hard cap + the whole pump
  channel, from the resolved format), so `extend` never reallocates on the realtime loop; the
  callback formats nothing.
- New `audio_vitals::PlaybackVitals` — atomics both twins publish from their device callback; the
  decode thread prints the same `audio playback buffer_ms= target_ms= underruns= drift_sheds=
  plc_ms=` line (plus the one-shot `audio playback quantum`) on wall clock, so a field-log grep
  keeps working and the WASAPI twin's line comes from the same site.
- The decode thread takes a best-effort nice −10 on Linux (rtkit is the follow-up).
2026-08-18 11:13:56 +02:00
enricobuehler dc2d946949 fix(host,audio): the pacer's schedule was wall clock, the source was not, and every hole left it further behind
The 2026-08-17 Skynet log: 33–72 % of audio departures "late", `max_late_ms` climbing to 99 while
`reanchors` stayed at 0 and `max_spacing_ms` at 13 — a schedule that had fallen behind and stayed
behind, sending frames 5 ms apart against a grid it never advanced for the audio the graph lost.
A missed 2.7 ms cycle or two is below the capture gap counter's floor and below the infill
threshold, so nothing covered it and `pace_due` kept the debt; the next >= 10 ms hole then repaid
it as a burst of (lag + 10) / 5 silence frames back to back. On the client that is a ring drained
one frame per uncompensated loss, then refilled with a burst it has to trim.

- The infill decision looks at the schedule LAG as well as the time since the last chunk: a
  schedule `after()` behind with no audio in hand is owed cover exactly like a hole that long.
  Lag is bounded at that; the debt burst is gone; `late` beyond that band is this thread's own
  scheduling, which is what WP-C built the counter for.
- `after()` follows the real quantum (`InfillPolicy::note_quantum`, in capture_policy): one chunk
  plus one frame, never less than two frames. Bit-identical at a frame-sized quantum; on a VM's
  clamped 1024-frame buffer the old flat 10 ms sat in the middle of a legitimate cycle, so a chunk
  a couple of ms late would have been covered and left a frame of surplus behind for good.
- A slot whose remaining backlog exceeds one chunk plus one frame sends a second frame in the same
  slot (at most two per slot). Left alone a fast source clock could only ever grow the backlog —
  one frame per slot, forever — five milliseconds of host latency every fifty seconds at 100 ppm.
- Holes no longer open on a step to digital zero and close mid-waveform: the first infill frame
  fades the partial (or the last real frame's head) out over 1 ms and the first real frame after
  the hole fades in over the same raised cosine (`pcm::raised_cosine_tail/head`, now public).
2026-08-18 11:13:44 +02:00
enricobuehler aa070d1f4a feat(host,audio): the capture line said sixty holes and could not say what shape they were
`gaps=60 max_gap_ms=146` reads the same for sixty 30 ms stalls (a periodic scheduler on the box)
and for fifty-nine 12 ms hiccups plus one outage, and those want different fixes. The 2026-08-17
Skynet log had that ambiguity across all 53 windows — 89–98 % delivered, gap counts alternating
high/low on consecutive 30 s windows, and no way to tell whether the loss was in the counted
holes or below the counter's floor.

- `CaptureStats::observe_gap` is the one accounting behind both feeds (the Linux callback cadence
  and the Windows discontinuity flag, which used to poke the fields directly) and now also buckets
  each hole (<20 / <50 / <100 / >=100 ms — the client-concealment edges) and sums the audio lost.
- Both capture lines print `gap_hist=a/b/c/d missing_ms=`; the sum closes the arithmetic against
  `delivered_pct`, so sub-threshold losses become the visible remainder.
- The Windows loopback READER thread now takes the same `boost_thread_priority(true)` the paced
  sender it feeds has carried since the data-plane QoS work; it ran at normal priority beside it.
2026-08-18 11:13:25 +02:00
enricobuehler eeed8ae8bb feat(clients/apple): the library model learns platform, and the collation rules arrive as a tested module
The first slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-1):

- `GameEntry.platform` — the host has sent it all along (`GameMeta`, flattened); `Codable`
  dropped it. It is the field the library's collections group by, and the detail band now
  reads `STORE · PLATFORM` when the host named one.
- `LibraryCollation` (PunktfunkKit) — the Swift port of `pf-console-ui`'s `collate.rs`:
  `LibrarySortKey` (host/title/platform/store, the desktop's stored ids), `LibraryGroupKey`,
  `collate`/`filtered`/`worthBrowsing`, the article-and-diacritic title fold, the
  platform-less-buckets-under-its-store rule, bytewise label order. The desktop's eight tests
  are ported by name, and `testVectorsMatchTheSharedFile` reads the same
  `clients/shared/library-collate-vectors.json` the Rust test reads — the two copies of the
  spec now share a machine contract.
- `LibraryOrder.display` replaces `LibraryView.ordered`, which put EVERY running entry first
  over `launchersFirst` — so a running game jumped ahead of the launcher prefix and the
  coverflow's heading read GAMES · LAUNCHERS · GAMES along the strip, while its comment
  claimed the opposite. The desktop's `order()`: launchers lead, running leads within a band.
- Three device keys with the desktop's ids (`librarySort`, `libraryView`,
  `libraryCollections`), and their rows on both settings surfaces (gamepad Interface tab,
  touch Library section). Presentation only, never in a profile.
- The coverflow now shows the catalog's staleness ("Last known library — waking the host…" /
  "— the host didn't answer") on its detail band — the touch grid had it, the coverflow
  never did — and opens on the title last launched from the shelf (`LibraryScrollMemory`),
  the way the grid scrolls back to it. `GamepadCarousel` gains `initialItemID` for that.
- Loading/error/empty in gamepad mode carry a legend (`A Retry` on an error, `B Back`); the
  zero-size back catcher answers A too. A controller-only user on an error screen had no
  visible way out.
- `PUNKTFUNK_FAKE_LIBRARY=<file.json>` — the desktop's dev hook, here: a host-shaped list (or
  the shared vectors file itself) stands in for a host, so grid/collections/sort can be
  driven on a Mac with none. `ShotMock.games` gains platforms for the store shots.
2026-08-18 11:08:45 +02:00
enricobuehler 5c6236aec9 fix(client/android): size the ASurfaceControl layer to the view, not the window buffer
On-glass, the ASC layer drew the picture into the top-left ~45% of the screen.
The dest rectangle came from ANativeWindow_getWidth/getHeight on the SurfaceView,
which returned the window's buffer geometry in a rotated/scaled space (1260x567
for a 2800x1260 full-bleed stream) — the ASurfaceControl child is composited in
the SurfaceView's on-screen coordinate space (2800x1260), so a 1260x567 dest
shrank it to the corner.

The presenter now takes the SurfaceView's on-screen pixel size, plumbed from
Kotlin (surfaceCreated -> nativeStartVideo -> DecodeOptions), and uses it as the
dest rect. This is the aspect-fitted video footprint, so it stays correct under
letterboxing and render-scale too, where the decoded buffer size would not. A
non-positive value (view not laid out yet) falls back to the window buffer size.

Verified on glass (NP3 -> 2800x1260@120 HDR): "asc: layer created, dest 2800x1260"
and the picture fills the screen; e2e p50 ~15-19 ms, no policy skips. cargo ndk
arm64 check + clippy + rustfmt clean; Kotlin compiles via the gradle build that
installed it. (The panel still latching at 60 Hz is a separate frame-rate-pin
issue, not this fix.)
2026-08-18 11:04:41 +02:00
enricobuehler a8ee7cd71b test(console): the collation rules become a shared vectors file the other clients can pin
`clients/shared/library-collate-vectors.json` pins, from `collate.rs`'s rules, the exact groups
every (sort, group_by) must produce over one mixed library, the flat lists a group filter
yields, which libraries are worth browsing, the title fold, the store labels and the persisted
sort ids. `vectors_match_the_shared_file` reads it here; the Apple port's LibraryCollationTests
reads the same file, so the three copies of the spec have a machine contract instead of prose.
Desktop stays the source of truth — a rule change regenerates the file in the same commit.

The fixture deliberately walks every rule the design doc names: a platform-less Steam title
buckets under Steam, a generic-store title under Other, launchers lead in host order under
every sort, equal A–Z keys keep the host's order, group labels sort bytewise (GOG before Game),
a filter naming a missing group yields nothing, and `filtered(None)` flattens the launcher
group too.
2026-08-18 10:59:32 +02:00
enricobuehler 838892ce1c Merge pull request 'The usbip pad acknowledged every write with 0 bytes, so GE-Proton's haptics enable "failed"' (#291) from worktree-usbip-out-actual-length into main
Reviewed-on: unom/punktfunk#291
2026-08-18 08:55:17 +00:00
enricobuehler 95637f3226 feat(client/android): present through ASurfaceControl, scheduled on the panel's real clock
The field report was the Nothing Phone 3 — the exact device the latency program
was tuned on — reading e2e p50 30 ms / p95 39 ms with 40-50 skipped frames a
second, and switching to Smooth changed nothing. That is a present-path failure,
not a decode wall: this SoC decodes a frame in ~4-5 ms, 240 fps of headroom.
Dropping ~40% of frames by policy while the survivors land 1-2 refreshes late is
the signature of the presenter scheduling against a clock that lies. Two facts
underneath it: Android down-rates a game uid's choreographer stream (so the panel
grid the SurfaceView presenter learns can read 60 on a 120 panel), and its glass
budget reopens on a *predicted* latch that, when wrong, backpressures the codec.

The SurfaceView path can only predict SurfaceFlinger's latch and hope the
best-effort OnFrameRendered callbacks arrive. This adds the Android equivalent of
what the Apple client gets from CAMetalDisplayLink + preferredFrameLatency=1: the
codec renders into an AImageReader, and each frame is composited onto an
ASurfaceControl layer via a transaction carrying a desired present time. Every
applied transaction reports its *real* latch time and the previous buffer's
release fence on completion, so the panel period is learned from real latch
spacings (no down-rate lie), the glass budget is bounded by real completions (no
mispredicted reopen), and the display stage is always measured (not best-effort).

Both present intents ride the one actuator — the transaction's desired present
time: latency (default) is newest-wins at the next real vsync; smooth drains a
small FIFO on each frame's CadenceClock due time, now with a truthful clock
beneath the loop that could not lock before. The re-anchor gate, ABR decode
signal, decode-stage HUD split, and the audio plane's video-e2e reference all
keep their existing seams.

ASurfaceControl is the default. It falls back to the SurfaceView presenter,
byte-for-byte unchanged, on API < 29, any init failure (null layer, ImageReader),
or debug.punktfunk.present_backend=surfaceview (the field escape hatch, no
rebuild). Memory safety does not rest on the release fences — an AImage keeps its
buffer alive through SurfaceFlinger's own reference, so a mishandled fence at
worst reuses a buffer early (a tear), never a use-after-free — which is what lets
this default in behind the auto-fallback.

ASurfaceControl/ASurfaceTransaction are not in ndk-sys 0.6, so surface_control.rs
hand-declares them and resolves every entry point via dlsym from libandroid.so,
the same >-floor-symbol pattern adpf.rs and vsync.rs already use (all the
ASurface* entry points are API 29, above minSdk 28). AImageReader/AHardwareBuffer
come from the vendored ndk crate.

Verified: cargo ndk arm64-v8a + armeabi-v7a check, clippy, and the arm64 .so
links; rustfmt clean. NOT YET RUN ON A DEVICE — the on-glass A/B on the NP3 (both
modes, vs the SurfaceView backend via the sysprop) is the acceptance gate before
this is trusted as the default; the fallback makes shipping it default safe in
the meantime.
2026-08-18 10:47:08 +02:00
enricobuehler a519491928 fix(pad): the pad's sound card was root-only, so PipeWire never even saw it
The usbip DualSense's ALSA card is minted mid-session-bringup while no seat session is
active, so logind's uaccess ACL never materialises and /dev/snd/controlC*/pcmC* stay
root:audio 0660 with the user in neither. WirePlumber's probe fails EACCES ("spa.alsa:
can't open control for card hw:2: Permission denied"), the card never appears in PipeWire,
no pad sink exists for winepulse to route to — which is why every mmdevapi endpoint in the
GE logs was a punktfunk-speaker and the audio ContainerIds were all GUID_NULL — and
GE-Proton's direct ALSA haptics leg (find_dualsense_haptic_alsa_path) cannot open the PCM
either. Same mechanism the hidraw rules in this file already handle, one subsystem over.

Verified live on .41 (Bazzite f44): installing the rule + udevadm trigger made WirePlumber
adopt the card mid-session — device, Default__Speaker__sink and Mic source all appeared,
under exactly the alsa_output.usb-Sony_Interactive_Entertainment_ name prefix GE matches.
2026-08-18 10:46:24 +02:00
enricobuehler b52733d287 Merge pull request 'One info! line in a TLS destructor aborted the host on every session teardown' (#289) from worktree-win-teardown-abort into main 2026-08-18 08:43:31 +00:00
enricobuehler faa00ed142 fix(usbip): an OUT reply said 0 bytes accepted, so every hidraw write on the pad "failed"
GE-Proton's `hidraw_enable_dualsense_usb_haptics` never enabled the DualSense's USB haptics
mode against our usbip pad — `err:hid:hidraw_device_set_output_report id 2 write failed
error: 2 No such file or directory`, then feature report 0x08 retried forever with
EINVAL/EAGAIN. Adaptive triggers, voice-coil haptics and the speaker are all gated behind
that one enable, so nothing downstream could ever show a result. Five theories were ruled
out by log inspection; the sixth was measured on the live pad on .41 today:

    write(hidraw, output 0x02, 48 B)  -> 0
    ioctl(HIDIOCSFEATURE 0x08, 48 B)  -> 0
    ioctl(HIDIOCGFEATURE 0x05, 41 B)  -> 41

The vendored simulator answered every non-isochronous OUT URB through the IN constructor
with an empty buffer, i.e. `actual_length = 0` — and a debug_assert pinned that as the
rule ("OUT nothing"). vhci_hcd copies the field into `urb->actual_length` verbatim
(`usbip_pack_pdu(pdu, urb, USBIP_RET_SUBMIT, 0)` in `vhci_recv_ret_submit()`) and has no
other source for it, so `usbhid_output_report()` returned 0 as `write()`'s byte count and
`usb_control_msg()` returned 0 for the SET_REPORT data stage. winebus checks `count > 0`,
takes 0 as failure, and prints the thread's *stale* errno — the ENOENT/EINVAL/EAGAIN in the
log were never kernel verdicts. The earlier `/tmp/hidwrite.py` "150/150 ok" was the same
illusion: `os.write` returning 0 does not raise. A real usbip stub reports the real URB's
`actual_length`, which on OUT is the bytes sent.

Fix: `UsbIpResponse::usbip_ret_submit_out_success(header, accepted)` acknowledges the bytes
taken (`data.len()`, which `read_from_socket` sized from `transfer_buffer_length`) with no
payload back; the handler uses it for OUT; the assertion now pins "OUT carries no buffer",
not "OUT claims 0". Two wire-byte tests pin both directions. The Steam Controller 2 shares
this handler, so its OUT writes were being reported as 0 bytes too.

`scripts/usbip-trace-analyse.py` flagged ANY nonzero OUT actual_length as a desync — the
wrong rule (its own framing never reads a payload back on OUT) and one that would have hid
this bug and flagged the fix. It now flags an OUT reply claiming more than it was sent, or
0 against a non-empty write.
2026-08-18 10:32:40 +02:00
enricobuehler 4cd35e15ca fix(host): one info! line in a TLS destructor aborted the host on every session teardown
A GameStream session ending killed the whole `punktfunk-host` process on Windows.
The SCM restarted it ~6 s later, so in the field it read as a mystery reconnect
rather than a crash. Four occurrences on .173, every one of them a session
teardown, on the canary and on a branch build alike.

`HotThreadGuard` is parked in a `thread_local!` so the last hot thread out
reverts the process-wide session tuning. Its `Drop` is therefore a **TLS
destructor**, and it called `tracing::info!`. By then this thread's other
thread-locals may already be gone — `tracing_subscriber`'s registry is
`sharded-slab`-backed and reads a `thread_local!` through `LocalKey::with` — so
the event panicked with "cannot access a Thread Local Storage value during or
after destruction". A panic that escapes a TLS destructor is fatal in Rust:
`fatal runtime error: thread local panicked on drop, aborting`.

The panic hook then hid the evidence. It teed through `tracing` too, so it
panicked the same way — and a panic raised while the hook runs is
`MustAbort::PanicInHook`, where std deliberately does not format the message
("perhaps that is causing the panic"). The log got `panicked at <loc>:`, a BLANK
line, and `thread panicked while processing panic. aborting.` The one string
naming the cause was erased at exactly the moment it mattered.

* `untune_process` no longer logs. The revert is only FFI and stays inside the
  refcount lock, so it is still atomic against a session starting concurrently.
  Its counterpart "applied" line runs on a live thread and is kept.
* The panic hook writes straight to the `LogRing` instead of through `tracing`.
  The ring is a `OnceLock` + `Mutex`, so it is TLS-free; `thread::current()` and
  `Backtrace::force_capture()` were both verified safe during TLS destruction.
  This does not make a TLS-destructor panic survivable — Rust aborts on those
  regardless — but the message that names the cause now always lands.

Reproduced standalone before fixing, against the same 1.96.0 toolchain, and the
output is byte-identical to the field log down to the blank line. That harness
also settles which half is load-bearing: destructor-logging alone aborts, hook
hardening alone still aborts (with a readable message), and removing the
destructor's log exits cleanly.
2026-08-18 10:30:35 +02:00
enricobuehler 700275fa0d Merge pull request 'A Windows launch is a hand-off, and 0.30 read its exit as the game's' (#288) from worktree-win-game-launch-untracked into main
Reviewed-on: unom/punktfunk#288
2026-08-18 08:19:50 +00:00
enricobuehler 6352ff629d fix(host/library): a Windows launch is a hand-off, and 0.30 read its exit as the game's
A user launched a title on a 0.30 Windows host and watched the game come up while
the stream dropped, with the console reporting nothing running. Two changes from
0.30 meet here, and the second turns the first into a disconnect.

`LeaseRequest::spawned` (0.30) started carrying the pid `CreateProcessAsUserW`
hands back, so a Windows launch could finally be a `LeaseKind::Child` instead of
degrading to `Untracked`. But `windows_launch_for` builds a protocol hand-off for
almost every kind — `explorer.exe "playnite://…"`, `Steam.exe "steam://…"`,
`explorer.exe "shell:AppsFolder\…"` — so that pid is a forwarder's, not the
game's, and it says nothing about the game in either direction: it quits a second
after launch when the launcher was already running, and it *becomes* the launcher
when it was not.

The watcher's shim window exists for exactly this, and both callers of the pid
path documented it as their protection — but it was skipped whenever the title
had no detect signals, which is the one shape that needs it. So a hint-less title
(a Playnite entry whose install dir Playnite does not know, a custom entry with no
hint) reported `running` on its first poll because the forwarder was alive, and
`exited` a second later when the forwarder quit: `finish` fired `on_exit`, which
closes the connection with `APP_EXITED`. In 0.29 the same title was `Untracked`,
nothing watched it, and the session stayed up — which is the "it used to just show
as running" the report describes.

The forwarder pid is not only a false liveness signal, it is a termination target:
`windows_term_ladder` adds `shared.spawned` to the pids it `WM_CLOSE`s and then
terminates. A Steam title launched while Steam was closed makes that pid the Steam
*client*, so `POST /game/end`, a grace expiry or `game_on_session_end` would ask
the player's whole Steam client to close rather than the game.

* The Windows recipe table now says which lines start the game and which only
  forward it (`WinRecipe::owns_game`); a forwarder's pid is dropped rather than
  carried, so the lease falls back to detect signals exactly as it did before.
  Only `gog` (the resolved exe), `command` and a plugin's own recipe (`cmd.exe /c`
  blocks on them) own their game.
* The shim window now applies to a bare child or pid whatever the spec holds. An
  empty spec is fewer reasons to trust the child, not more.
* Giving up on tracking lands the lease on `GameState::Untracked` instead of
  leaving the console on `launching` forever — the same honest answer `open`
  reaches when it starts no watcher at all.

`a_pid_only_launch_reports_its_exit` used a 4-second fixture, inside the shim
window; it passed only because of the bug above. It is 8 seconds now, and a new
ignored test drives the field report itself: a pid that hands off inside the
window with no signals must not end the session, and must leave the row
`untracked`. Backing the phase-1 change out fails that test with `on_exit` fired
once — the disconnect, reproduced.

Gates. Windows .173: `clippy --all-targets -D warnings` clean (non-vacuous), and
`library::launch` 11 + `gamelease` 12 tests pass. Linux (punktfunk-rust-ci): fmt
clean, `clippy --all-targets -D warnings` clean, `gamelease` 14 pass, and all
three `--ignored` live-process tests pass.
2026-08-18 09:30:57 +02:00
enricobuehler 33538582e2 Merge pull request 'The usbip DualSense died because its calibration report was one byte too long' (#287) from worktree-usbip-dualsense-fix into main
Reviewed-on: unom/punktfunk#287
2026-08-17 15:01:52 +00:00
enricobuehler 8e8cc84d1a fix(pad): the usbip DualSense died because its calibration report was one byte too long
`PUNKTFUNK_DUALSENSE_USBIP=1` enumerated the pad and then lost it ~400 ms later,
taking the controller with it (the usbip transport replaces uhid, so there was
nothing to fall back to). Three sessions blamed the ISO stream, the link speed and
`actual_length` in turn. It was none of them.

`DS_FEATURE_CALIBRATION` is 42 bytes. `hid-playstation` asks for 41
(`DS_FEATURE_REPORT_CALIBRATION_SIZE`), and on a USB backend an over-long reply is
not truncated, it is fatal to the transport:

    size = urb->actual_length;                 /* 42, what we declared */
    if (size > urb->transfer_buffer_length)    /* 42 > 41 */
            goto error;                        /* "probably malicious packet" */
    error:
            dev_err(&urb->dev->dev, "recv xbuf, %d\n", ret);   /* ret still 0 */
            usbip_event_add(ud, VDEV_EVENT_ERROR_TCP);

`VDEV_EVENT_ERROR_TCP` tears down the whole connection, not the one URB — hence
`recv xbuf, 0` (that 0 is the untouched initialiser, not a byte count), then
-EPROTO on the calibration read, `Failed to create dualsense`, and the disconnect.
The dmesg order made the teardown look like the cause; it was the consequence.

The blob had been wrong since it was written, and a FIXME said so. It stayed
invisible because every other backend truncates: hidraw for the uhid pad, hidclass
on Windows. USB/IP is the first transport that checks.

Three changes, because one of them alone would leave the same trap set:

- Trim the constant to 41 and pin all three feature-report sizes in a test.
- Clamp every reply to the requested length in the transport (`clamp_reply`), and
  drop any payload a handler returns on an OUT transfer — the kernel never reads
  one, so those bytes would misframe every PDU after them. A handler bug now costs
  one wrong reply instead of the device.
- `DualSenseUsbip::open` waits for the kernel to actually bind a HID driver before
  reporting success. A `vhci_hcd` attach succeeds immediately and enumerates
  asynchronously, so bringup faults were being reported as working pads; now they
  return Err and the caller's existing uhid fallback catches them.

Also adds `PUNKTFUNK_USBIP_TRACE` (both socket directions to disk) and
`scripts/usbip-trace-analyse.py`, which walks a capture and names the first frame
whose declared length disagrees with what the kernel will consume. The handler's
Err arm is no longer discarded either — it was the only signal distinguishing "we
dropped the connection" from "the kernel did", and both read identically in dmesg.

Verified on .21 (CachyOS, kernel 7.1.8): `Registered DualSense controller
hw_version=0x01000208 fw_version=0x01000036`, the device stays enumerated, and
snd-usb-audio mints a real ALSA card. Audio over the isochronous endpoint now runs
for the first time — a 300 Hz tone on the coil pair reads back channel-exact
(peak_coils=0.5000, peak_speaker=0.0000) for the whole run. A 4957-frame capture
analyses clean.
2026-08-17 16:54:37 +02:00
enricobuehler af1717e9f4 Merge pull request 'An ISO OUT reply claimed zero bytes consumed, and the kernel dropped the whole device' (#286) from worktree-usbip-iso-actual-length into main
Reviewed-on: unom/punktfunk#286
2026-08-17 12:59:57 +00:00
enricobuehler 1c9aec35fe fix(usbip): an ISO OUT reply claimed zero bytes consumed, and the kernel dropped the whole device
Turning on PUNKTFUNK_DUALSENSE_USBIP=1 made the DualSense vanish from the host entirely - unusable in game and absent from Steam's controller list. The usbip path replaces uhid, so there is no fallback.

Enumeration was never the problem: the kernel bound both drivers to the virtual pad ('playstation ... hidraw6: USB HID v1.11 Gamepad [Sony Interactive Entertainment DualSense Wireless Controller]' and snd-usb-audio processing the mixer). ~56 ms later, as soon as snd-usb-audio submitted the first isochronous OUT URB, the link tore down:

    usb 11-1: recv xbuf, 0
    vhci_hcd: sendmsg failed!, ret=-32 for 48
    vhci_hcd vhci_hcd.0: stop threads / release socket / disconnect device

usbip_ret_submit_iso filled each packet descriptor's actual_length correctly (the bytes accepted, = the requested length on OUT) but set the URB-level actual_length from transfer_buffer.len(), and the transfer buffer is only populated INBOUND. So an OUT reply stated per-packet actuals summing to N x 392 against a total of 0.

The kernel's usbip_recv_iso() sums the packet table and compares it to the URB's actual_length; on a mismatch it raises ERROR_TCP and returns -EPIPE, which drops the connection rather than the URB - hence the pad disappearing outright and the attach loop retrying forever. The function's own doc comment already stated the required semantics; only the aggregate missed them.

actual_length is now the running total of the per-packet actuals, which is unchanged for IN (where it equals the buffer) and correct for OUT. to_bytes()'s debug_assert encoded the same wrong rule for ISO and now checks the kernel's invariant directly, against the descriptor table.

The OUT unit test asserted actual_length == 0 - it had locked in the bug, conflating 'sends no payload back' with 'consumed nothing'. It now pins the real invariant, sum(table actual_length) == actual_length.

Not yet re-tested on hardware.
2026-08-17 14:57:25 +02:00
enricobuehler 223647b58f Merge pull request '0.30.0' (#285) from worktree-release-0-30-0-prep into main
Reviewed-on: unom/punktfunk#285
2026-08-17 12:49:55 +00:00
enricobuehler 7f969cc429 Merge pull request 'Library launches opened on the operator's head — nothing ever focused the streamed one' (#283) from worktree-hyprland-focus-streamed-output into main
Reviewed-on: unom/punktfunk#283
2026-08-17 12:49:25 +00:00
enricobuehler 77a8ccbbcd fix(vdisplay): library launches opened on the operator's head — nothing ever focused the streamed one
Reported on Hyprland: "anything from the library opens on my main display instead of
the virtual screen."

Hyprland and wlroots/sway are the two EXTEND-only backends: the per-session headless
output is added *beside* the operator's heads and nothing promotes it (KWin and Mutter
promote theirs primary; gamescope nests the app in its own compositor). Both compositors
open a new window on the FOCUSED monitor, and `output create headless` / `create_output`
does not focus what it creates — so focus stayed on the physical head and every library
launch opened where the client could not see it, with the stream showing a bare desktop.

Nothing in the tree moved or focused a window: no `focusmonitor`, no window rule, no
post-map correction. The launch path's own doc comment asserted the opposite ("KWin /
Mutter / wlroots ... the per-session virtual output is promoted primary, so a plain spawn
lands the app on the streamed output") — false for wlroots, and Hyprland was not listed.

Both backends now claim focus for the head they are about to stream, and the launch path
re-asserts it immediately before the spawn: the portal handshake, the encoder build and
the first frame all sit between capture bring-up and the launch, so a create-time focus
alone can be lost before the app ever starts. The name comes from the same slot the
absolute-input pointer is bound to, so focus and cursor land on one head by construction.

`focus_streamed_output` is gated on each backend's own managed-name predicate, which keeps
the mirror-pin case out — there the streamed head is a physical monitor the operator is
using, and stealing its focus is a change nobody asked for. Its match is exhaustive on
purpose so a backend added later has to decide rather than inherit "no focus" silently.

This does not implement the `primary`/`exclusive` topology levels on these two backends —
they are still accepted, echoed by the management API and dropped with a warning (sweep
13.18, whose shipped fix was the warning only). Focus is the extend-topology answer to
window placement and leaves the operator's monitors lit.

Tests pin the two argv shapes, which is where this can silently regress: `focusmonitor` is
a dispatcher and needs `hyprctl dispatch`, while sway spells its command `focus output
<name>` with the noun second, unlike every other call in that file. Both wrong forms are
rejected at exit 0 / with an error whose only field symptom is the bug itself.
2026-08-17 14:25:29 +02:00
enricobuehler b65e787fca release: 0.30.0 — version bump, notes, CHANGELOG, Play notes
175 commits since v0.29.0 (131 non-merge). Cut from origin/main a17a8917.
Re-cut from d92266a4 (129/99) as main moved through PRs #270-#282.

THE NUMBER: 0.30.0 is forced, not chosen. The C ABI moved 20 -> 24 in
four steps this cycle — 21 connect_ex10 (the device_name an unpaired
client knocks with, #244), 22 the per-client access accessors (#245),
23 connection_audio_plc (Apple's drought concealment), 24 connect_ex11
plus the resolved-format accessors (the lossless plane, #263) — and
gamescope moved +pfhdr7 -> +pfhdr8 (patch 0010, the seat keymap the
keyboard-layout fix depends on). Two DEFAULTS also flipped, either of
which alone rules out a patch: the host now serves the lossless audio
plane unless refused (#280, marked breaking by its author), and
trust::Settings::library_enabled is deleted along with the "Show game
library" row (#273, likewise). scripts/ci/pf-version.sh derives the
canary base as latest stable + one minor, so canaries move 0.30.x ->
0.31.x after the tag.

Every C addition is additive and WIRE_VERSION stays 2: the new Hello
and Welcome fields ride the trailing-field append discipline, and the
one new control message (PipelineGap, type byte 0x0A) took a free byte
in the block it belongs to. No existing C function changed signature or
behaviour, and no repr(C) struct grew a field — the lossless format is
read through accessors precisely because PunktfunkAudioPcm and
PunktfunkStats have no struct_size guard and are allocated by value by
every C embedder.

Version table re-measured on a17a8917, not carried forward from the
first cut: ABI 24, wire 2, driver protocol 6 / min 3 (pf-driver-proto
has no diff against the v0.29.0 tag), gamepad channel 3, plugin index
schema 1, edition 2024, MSRV 1.85, 27 crate dirs, SDK 0.1.4,
plugin-kit 0.4.2, gamescope 8, api/openapi.json stamped 0.29.0.

⚠ RE-SYNC OWED AND DONE: docs-site/public/openapi.json had drifted from
api/openapi.json. #276's game-lifetime work regenerated the api/ copy
alone (adding GameOnNewLaunch, game_on_new_launch and the `untracked`
game state) and nothing catches it — the mgmt drift test compares the
LIVE ROUTE TABLE against the generated document, not the two files —
so CI stayed green while the published API reference described 0.29.
Re-synced byte-identical here and called out in the CHANGELOG.

Three claims were corrected against the code rather than shipped as
drafted, all three in the first cut's favour of being too narrow or too
broad:
  * lossless rungs are NOT uniform across clients. Apple and Android
    list the full ladder (441/48/882/96/1764); pf_client_core lists
    only 48/96, and pf-client-core still filters a surround request out
    before the wire, so the desktop and console clients stay on Opus
    under 5.1. The notes say exactly that instead of "stereo only".
  * "send logs to host" ships on the Gaming Mode console alone — the
    Apple, Android and webOS legs are follow-ups.
  * the masked-color I-beam fix arrived via a fork branch but is
    authored by the repo owner, so it is NOT credited as an outside
    contribution.

Gates run on this tree: cargo fmt --all --check clean; cargo metadata
--locked ok; Cargo.lock diff versions-only (36/36 lines) against the
new main; cargo test -p punktfunk-core green including the c_abi
harness (261 + 1 + 8, the ABI-24 header compiles and round-trips;
macOS needs LIBRARY_PATH=/opt/homebrew/opt/opus/lib); the android.yml
Play notes gate run verbatim — 475/500 characters and not byte-
identical to any prior release's; notes voice scan finds no internal
names outside the For developers section; both openapi copies diff
clean.
2026-08-17 13:37:21 +02:00
enricobuehler a17a8917f4 Merge pull request 'A game can't pair the virtual pad with its speaker, because both ContainerIDs need one real usb_device' (#282) from worktree-dualsense-usbip-audio into main
Reviewed-on: unom/punktfunk#282
2026-08-17 11:22:18 +00:00
enricobuehler 3c53f484fc Merge pull request 'The pad speaker's path byte was pinned on a ~5x sweep, and the on-glass numbers are ~300x' (#281) from worktree-ds5-speaker-enable into main
Reviewed-on: unom/punktfunk#281
2026-08-17 11:19:19 +00:00
enricobuehler e68f411f34 Merge pull request 'The lossless audio plane stops being an operator opt-in — PUNKTFUNK_AUDIO_HIRES defaults on' (#280) from worktree-audio-hires-default-on into main
Reviewed-on: unom/punktfunk#280
2026-08-17 11:18:56 +00:00
enricobuehler 9a163d4ebf feat(host/audio)!: the lossless plane stops being an operator opt-in — PUNKTFUNK_AUDIO_HIRES defaults on
A user picked "Lossless 96 kHz / 24-bit" in the macOS client, connected to a
bazzite host, and got Opus. Nothing in any UI said why; the reason was one INFO
line in the host's journal saying PUNKTFUNK_AUDIO_HIRES was not enabled.

So flip the host half: default ON, explicit-off grammar, `=0` to refuse. That
makes it the same shape as PUNKTFUNK_444, PUNKTFUNK_CHACHA20 and
PUNKTFUNK_10BIT, and the field stops being the one `Option<bool>` in
pf-host-config read as `unwrap_or(false)` — it is now a plain `bool`.

The old default rested on "this spends bandwidth the host's owner never agreed
to". Every clause of that is still true, but the operator is not who spends it —
the client's user is, and §8.4 condition 1 is already that user's explicit menu
choice, which still ships OFF. What was really being protected was nothing: the
conditions that keep a link safe are mechanical, not consent-based (the capture
path must honestly deliver the rate, the cost must fit a quarter of the
session's video bitrate, and a frame must fit a datagram). A 5 Mbps session
still cannot buy 96/24. The operator gate was not keeping modest links safe, it
was keeping the feature unreachable.

No wire or ABI movement: HOST_CAP_AUDIO_HIRES is set only when a session
actually resolved to PCM, so it stays a statement about that session's wire
rather than a capability advert, and an ordinary session (no
CLIENT_CAP_AUDIO_HIRES) is byte-identical to before.

Also:

- The decline log now names the opt-out and the value it must have, and tells
  the operator to remove the line. The old wording sent people looking for
  something to enable, which is now exactly backwards.
- The capture-rate probe's short-circuit guarantee ("an ordinary session must
  not pay COM work for a feature nobody asked for") now rests on `hires_asked`
  ALONE, so that condition has to stay first. Noted where it matters.
- The four Android comments warning that sending 48000/16 as a stand-in for
  "default" silently opts users into PCM described the blast radius as "any host
  with PUNKTFUNK_AUDIO_HIRES=1". That is now every host that has not
  deliberately opted out, so those warnings are strengthened, not softened — the
  0/0 sentinel is load-bearing in a way it was not before.
- The "must be asked for at BOTH ends" claim is corrected in the Apple, Android
  and pf-client-core settings docs, and in the two docs-site surfaces. Those
  rows also carried pre-flip staleness (48/96 kHz only, stereo only, 1.5-4.6
  Mbps) that predates the 44.1 family and hi-res surround landing; the figures
  are now 44.1-176.4 kHz, stereo through 7.1, 1.4-8.5 Mbps.

Gated on linux/amd64 (punktfunk-rust-ci): clippy --all-targets -D warnings
clean, punktfunk-host 597 passed / 0 failed, pf-host-config 14 passed / 0
failed, both crates verified non-vacuously compiled.
2026-08-17 13:14:11 +02:00
enricobuehler 7535afe8b0 Merge pull request 'The flatpak build's "offline" step was never offline — and 15 of its 21 minutes were setup' (#279) from worktree-flatpak-offline-build-updates into main
Reviewed-on: unom/punktfunk#279
2026-08-17 11:11:57 +00:00
enricobuehler 74faee315d feat(pad-audio): capture the pad's audio off its USB endpoint when the pad is a real USB device
With the usbip pad the host mints nothing, so there is no sink to capture from:
PipeWire builds the pad's real sinks from its real ALSA card. Everything a game
writes — PipeWire-mixed or a raw hw:X,0 grab alike — converges on the pad's
isochronous OUT endpoint, so capture there instead. That is the same point a
physical pad's samples reach, which is what makes any route a game takes land
in one place. The 0xD1 wire path downstream is untouched; only the source moves.

The two capture modes are mutually exclusive by construction, and the choice is
read from the transport flag rather than from whether a stream happens to have
been published yet — otherwise the race between pad arrival and the streamer
thread starting would decide it, and losing that race would mint a duplicate,
competing node graph over a real card.

Adds `pad-usbip-test`, the on-glass gate for all of this with no client and no
game involved: it attaches the pad, then reports in the order the failures
happen — whether the device enumerated, whether snd-usb-audio produced the real
ALSA card GE's scan needs, which usb_device node both ContainerId derivations
must land on, and finally the per-pair sample peaks that pad-sink-test already
reports (ch0/1 speaker, ch2/3 coils), so a channel-order slip in the UAC
descriptors cannot hide behind a healthy-looking global peak.
2026-08-17 13:02:08 +02:00
enricobuehler e2b37c6050 feat(pad): the virtual DualSense can arrive as a real USB device with its own sound card
A game that drives DualSense haptics pairs "my controller" with "my
controller's speaker" by Windows ContainerId, and wine derives that by walking
the HID device through udev up to a usb_device parent. Our pad is uhid, so its
sysfs chain is /sys/devices/virtual/misc/uhid/... with no USB ancestor
anywhere: winebus logs "Failed to get parent device." and every endpoint
registers as GUID_NULL. Measured against Spider-Man Remastered under
GE-Proton11-5, the game resolves both DualSense endpoints, reads their
FriendlyName and PhysicalSpeakers, and then declines to open either — and
GE's own retarget hook reports "No live Sony controller mono streams were
registered", because the game never created one.

The same missing fact blocks GE's other route. Its haptic path finds the pad by
enumerating real ALSA *cards* (snd_card_next -> snd_ctl_pcm_next_device ->
snd_pcm_open demanding 48 kHz / S16 / 4 channels). Minted PipeWire nodes are not
ALSA cards and snd_card_next cannot see them however faithfully their proplist
impersonates one — which is why pad_sink never sets api.alsa.path. In the field
log that scan never ran at all: zero "Checking ALSA card" lines.

So present the pad as a real USB device over vhci_hcd, reproducing the
hardware's own 4-interface composite layout from an lsusb capture of a wired
054c:0ce6 — audio control, audio streaming out (isochronous, S16LE 4ch 48 kHz,
the haptics + speaker), audio streaming in (the headset mic), and HID. Because
interfaces 0-2 are a genuine UAC 1.0 device, snd-usb-audio binds them and mints
a real ALSA card named "DualSense Wireless Controller", and PipeWire's ALSA
monitor builds the HiFi__Speaker__sink / HiFi__SpeakerHaptic__sink nodes itself
from the distro's DualSense UCM. The node graph stops being impersonated.

The transport rides the ladder steam_controller already established
(usbip -> uhid, degrading on failure) on the seam steam_usbip::attach_device
already exposes, and reuses the udev grant packaging already ships for the
virtual Deck — so this needs no new privilege, module or packaging.

Opt-in behind PUNKTFUNK_DUALSENSE_USBIP=1 while it awaits on-glass
verification: it changes the pad's whole kernel presentation, including
superseding the pad-audio sinks with a real card.

The descriptor set is pinned by test against the hardware's published
wTotalLength of 0x00E3, which is the cheapest check that the terminal topology,
both streaming interfaces with their alt settings, the 9-byte isochronous
endpoints and the HID interface are all shaped like the real pad rather than
merely self-consistent.
2026-08-17 13:01:54 +02:00
enricobuehler 52aedb30c3 feat(usbip): the vendored simulator learns alternate settings and isochronous transfers
A USB Audio Class device needs both and the crate had neither, so the only USB
devices it could express were HID ones.

Alternate settings: upstream emits exactly one interface descriptor per
interface with bAlternateSetting hardcoded to 0. UAC requires alt 0 to be the
zero-bandwidth setting and the streaming endpoint to live on alt 1, so the host
can release bus bandwidth while the stream is idle. Endpoint descriptors also
gained an in-descriptor tail, because a UAC isochronous endpoint is the 9-byte
form carrying bRefresh + bSynchAddress rather than the plain 7.

Isochronous transfers: the wire parser already read number_of_packets and the
iso_packet_descriptor table off the socket, and then dropped both on the floor,
always replying with an empty table — which stalls any ISO endpoint. The reply
now restates the table per packet. Two details are load-bearing:

  - actual_length means "bytes the device transferred", and that differs by
    direction. On IN it is the payload produced; on OUT there is no payload and
    it is the number of bytes *accepted*. Reporting the empty OUT reply's length
    would tell the kernel the device swallowed nothing, and an audio stream would
    make no progress while looking perfectly healthy.
  - completion is paced by bInterval x packet count, because for an audio
    endpoint the completion rate *is* the device's sample clock. snd-usb-audio
    advances its PCM pointer from URB completions and has no other time
    reference, and vhci_hcd does not throttle the server side (the same reason
    the interrupt path is already paced).

Also fixes a latent decode bug the audio endpoints would have tripped over:
only bits 1..0 of bmAttributes are the transfer type — the rest carry the
synchronisation and usage type, so a real UAC endpoint reads 0x05 or 0x09.
Matching the whole byte yielded None for both and fell through to
unimplemented!(), panicking the connection task. Every endpoint the crate
shipped with was a plain 0x03 interrupt, so nothing had noticed.
2026-08-17 13:01:32 +02:00
enricobuehler 7e15160918 perf(ci/flatpak): run in the baked image, and stop pulling 3.84 GB to publish 28 MB
Two independent costs in the same job, both measured on run 18855 (21m23s green,
of which the cargo compile is 6m10s).

1. The toolchain. Swap fedora:43 for punktfunk-flatpak-ci and the two dnf steps
   (303 s) plus the 1.5 GB runtime cache restore (168 s) become image layers. The
   Tooling step stays as an ASSERTION rather than the usual no-op install guard:
   the only failure it can actually see is a :latest that lags a ci/ change, and
   dnf-ing on top of that would hide the drift while spending the time the image
   exists to save. The runtime cache step is deleted outright — with the runtimes
   baked it is not merely redundant but harmful, since restoring it would spend
   168 s overwriting the baked installation with an older copy of itself.

2. The seed. This is not really a build-time problem, it just presents as one.
   The step mirrored the entire published OSTree repo down from unom-1 over ssh,
   and because the local repo starts empty there is nothing to diff against, so
   it transferred all of it, every run — rsync said so itself:

       received 3,835,169,389 bytes … total size is 3,845,084,524  speedup is 1.00

   3.84 GB and 180 s off a Hetzner box to publish a ~28 MB commit, and the repo
   grows by that 28 MB every build: the upload runs without --delete (deliberate)
   and nothing ever pruned it. What the summary regeneration actually needs is
   each channel's TIP, so pull exactly that over HTTP with --depth=0 --mirror.
   The current channel's tip earns its place twice — it is also the new commit's
   parent, so --generate-static-deltas still emits the from-parent delta that
   makes `flatpak update` incremental.

   The invariant this step exists to protect is preserved and, if anything,
   tightened: it enumerates every app ref the published summary advertises and
   fails on any it cannot seed. Only a real 404 may continue with a fresh repo —
   treating a flaky link as "nothing published yet" is exactly how a single-branch
   summary comes to clobber the other channel, the "No such ref" bug the step's
   comment already documents. It also no longer needs a deploy secret, since it
   reads the public repo.

   Server-side growth is bounded by an `ostree prune --refs-only
   --keep-younger-than='30 days ago'` after the upload: it touches only commits no
   ref points at (superseded canaries) and spares anything recent, so a client
   mid-pull or a box a few builds behind still resolves every object it asks for.
   Guarded on ostree existing there and never allowed to fail the deploy — by that
   point the bundle and repo are published, and a full disk is a slower problem
   than a red release.

Expected: ~21 min -> ~9 min, with the 6 min compile as the floor. Making that
incremental is a different problem — no sccache is reachable from inside an
offline flatpak sandbox, and flatpak-builder wipes the module build dir.

⚠ BOOTSTRAP: the LAN registry must hold punktfunk-flatpak-ci:latest before this
runs. The merge that lands this fires docker.yml and flatpak.yml together, so
this job can lose the race and fail on the image pull; re-run it once docker.yml
is green. Noted in the workflow — nothing self-heals it, as the container never
starts to run a fallback.
2026-08-17 12:59:32 +02:00
enricobuehler 6e46734484 ci(flatpak): bake a builder image — the job reinstalled its whole toolchain every run
flatpak.yml is the last workflow still running a raw distro image and installing
its tools per run. Measured on run 18855, a green 21m23s build whose actual
cargo compile is 6m10s:

     63 s  dnf -y install nodejs
    240 s  Tooling — 330 packages
    168 s  actions/cache restore of ~/.local/share/flatpak (1.5 GB)

i.e. ~7.8 min of setup in front of a 6 min build. Every other heavy workflow
already solved this the same way (rust-ci, fedora-rpm, android-ci, arch-ci...):
a content-keyed image on the LAN registry, rebuilt only when ci/ changes.

The runtimes are the interesting half — a package-only image would leave the
single biggest step in place — and baking them turned out to be free of the
privilege problem it looks like it should have. VERIFIED in a plain `docker run`
container where bwrap was proven broken first ("bwrap: No permissions to
creating new namespace"): `flatpak install --user` still exited 0 and
`flatpak info --user` resolved the ref. flatpak's post-deploy triggers are the
only part that wants bwrap and they are best-effort, so this image needs none of
the --privileged the consuming job needs for flatpak-builder's real sandbox.

The image asserts each ref deployed rather than trusting the install's exit
status: an image that merely LOOKS warm would push 1.5 GB back onto every run,
where it reads as "flatpak got slow again" rather than as a broken image.

Related refs (GL.default{,-extra}, codecs-extra, Locale) are kept deliberately —
they are what --install-deps-only would otherwise pull per run, so a
--no-related image would look smaller and cost more. This moves the same content
the runtime cache already held from the cache server to the registry, where
Docker keeps it on the runner's disk instead of re-extracting it every run.

⚠ The runtime pins now live in two places: ci/flatpak-ci.Dockerfile's ARGs and
the manifest's runtime-version. Drift is not fatal (the prefetch downloads
whatever is missing, retried) but silently costs the win, so bump them together.
2026-08-17 12:58:59 +02:00
enricobuehler 5b2f795b13 fix(ci/flatpak): the "offline" build step re-fetched every git source, so one upstream 503 killed it
Run 18870 died 5 minutes into "Build the flatpak (offline — deps + sources
prefetched above)" with the prefetch step green:

    Downloading sources
    Fetching full git repo https://github.com/ValveSoftware/gamescope.git
    Fetching git repo https://gitlab.freedesktop.org/wlroots/wlroots.git, ref refs/tags/0.19.3
    Fetching git repo https://gitlab.freedesktop.org/emersion/libliftoff.git, ref refs/tags/v0.5.0
    Fetching full git repo https://github.com/Joshua-Ashton/vkroots
    Fetching git repo https://gitlab.freedesktop.org/emersion/libdisplay-info, ref refs/tags/0.3.0
    error: RPC failed; HTTP 503 curl 22 The requested URL returned error: 503
    fatal: expected 'acknowledgments'
    Failed to download sources: module gamescope-wsi-layer: Child process exited with code 128

That step was never actually offline. flatpak-builder runs the download phase
again as part of every build — builder-main.c calls builder_manifest_download()
unconditionally (only --disable-download skips it) and passes
`update_vcs = !opt_disable_updates`. With updates on (the default),
builder_git_mirror_repo() always does a live `git ls-remote` + `git fetch` for
every git source AND every submodule, even ones pinned to an immutable commit
sha. So the long compile step carried five unretried network fetches in front
of it, and gitlab.freedesktop.org returning 503 took the whole job down —
outside retry.sh, which only wraps the prefetch.

Pass --disable-updates ("only download missing sources, never update to latest
vcs version") to the build. builder_git_mirror_repo() then short-circuits on
`git cat-file -e <commit>` against the warm mirror and returns before any
network call. It cannot change what gets built: every `type: git` source in the
manifest is commit-pinned (gamescope, glm, stb — plus gamescope's submodules,
pinned by their gitlinks), so "don't update" is a semantic no-op. Anything
genuinely missing still downloads, so a cold state dir self-heals.

Same flag on the prefetch's --download-only, where it is already retried: it
makes a restored .flatpak-builder cache actually save network instead of
re-fetching pins that cannot have moved. That is ~2.5 min per run of
wlroots/libliftoff/vkroots/libdisplay-info — submodules this manifest never
even builds, since the module sets enable_gamescope=false; flatpak-builder
mirrors them only because it clones submodules by default.

build-flatpak.sh gets the same flag so local/Deck builds match CI.
2026-08-17 12:32:48 +02:00
enricobuehler 08f154be07 docs(client): the pad speaker's path byte was pinned on a ~5x sweep, and the on-glass numbers are ~300x
speaker_enable_packet's 0x20 was chosen from a sweep that only cleared the pad-mic noise floor by ~5x, and the value was left recorded as unsettled. A second sweep on glass (DS5 wired to a Steam Deck client, 330 Hz into the speaker pair) swept the whole byte: 0x20 and 0x30 sound, 0x10/0x40/0x50 do not, and the margin is ~300x rather than ~5x. So bit 5 is the speaker-path enable and bit 4 alone does nothing.

Also records why 0x20 stays the default over the marginally louder 0x30 (0x30 asserts a second path bit whose effect on the headphone leg was never measured), and two properties this one-shot depends on that were measured the same day: the setting persists (unchanged 40 s after a single write, SDL live on the pad) and survives the pad's USB audio stream stopping and restarting, so it needs no re-assertion when the renderer opens its output.

Verified end-to-end: forcing the pad to 0x00 dropped the pad mic to the 0.000000 floor and silenced the speaker; a fresh slot open restored it (0.000349, audible). Comment only - no behaviour change.
2026-08-17 12:15:12 +02:00
enricobuehler 6a049b490b Merge pull request 'The library round-trip reaches the other clients — C7 for Android and the console UI' (#278) from worktree-library-parity-c7 into main
Reviewed-on: unom/punktfunk#278
2026-08-17 08:55:24 +00:00
enricobuehler b93815ac6e fix(console): "Start in collections" stopped working once the catalog was cached, and the sort bar's focus wash was taller than the row it highlights
Two console-UI defects found on glass.

**1. The collections hand-over silently stopped happening — a C7 regression.**

`LibraryScreen::collections_upgrade` decided "the library in the shared model is the one MY fetch
produced" by OBSERVING a non-`Ready` phase from the render loop, and latching a `fetch_seen` flag
when it saw one. That only ever held because a fetch's first act was to block on the network: the
model sat at `Loading` for hundreds of milliseconds, so any frame caught it.

The catalog cache (`9e561f14`, C7 item 2) put a disk read in front of that. On a warm cache the
fetch thread publishes `Ready` about a millisecond after `Loading` — both inside a single 60 Hz
frame — so a shelf pushed over a host visited before went from the PREVIOUS host's `Ready` straight
to its own without any frame observing the `Loading` between them. `fetch_seen` stayed false, the
hand-over was refused for the life of the screen, and "Start in collections" did nothing. Worse the
more you used it: the failure needed a populated cache, so it appeared exactly on the hosts you open
most and never on a fresh install.

Fixed by not inferring it. `LibraryShared` gains a `fetch_epoch`, bumped once per fetch by a new
`begin_fetch()` (which the console binary now calls in place of `set_phase(Loading)`), and a shelf
records the epoch at the moment it is PUSHED — before its `FetchLibrary` is even queued. "Is this
list mine" becomes `fetch_epoch() != entry_epoch`: a counter cannot be missed the way a passing
state can, and it is exact rather than probabilistic. `fetch_seen` is gone.

Two regression tests, and the first is written so a phase-observing implementation cannot pass it —
there is deliberately no `collections_upgrade` call between `begin_fetch` and the cached list, which
is the whole point:
* `a_warm_cache_still_hands_over_though_no_frame_ever_saw_loading`
* `a_cached_list_from_before_the_push_is_still_refused` — the other half, so the fix cannot degrade
  into "always upgrade"

The existing tests that drove the old flag through a phase transition now model a real push
(`begin_fetch`), including the failed-fetch-then-retry case, where a retry is a second epoch.

**2. The sort/view bar's focus wash covered the band, not the row.**

`TabStrip` seats its pills 2 dp into whatever band it is given and draws them 30 tall, so the
library bar's content occupies 34 of its 46 dp band — the remaining 12 is the air before the field.
The focus wash was drawn over the whole `bar` rect, putting 2 dp above the pills and 14 below: a
backdrop its own content visibly sat high inside. It now covers the pill row's extent with
symmetric padding, so it is centred on the thing it highlights at any scale. `TAB_PILL_TOP` /
`TAB_PILL_H` are published from `widgets` and used by both the strip and the wash, so the two
cannot drift.

Deliberately NOT fixed by moving the pills: settings and collections hand `TabStrip` the same band
and draw no backdrop behind it, where "tabs, then air, then content" is the right rhythm. Centring
the row there would have crowded their lists for a defect neither has.

Also adds the screenshot scene that was missing — `07c-library-bar` at both palette poles. The bar's
wash only draws when the bar has focus, and no dump ever put it in that state, which is how a
mis-sized backdrop shipped. Shot on violet AND mint because the wash is `accent(0.14)` and a
translucent accent reads differently over a dark field than a pale one.

Verified: the Linux container gate over `pf-console-ui` + `pf-client-core` +
`punktfunk-client-session` — fmt, clippy `--all-targets -D warnings`, a plain build, and 177 + 219
tests green. Both new shots rendered and eyeballed: the wash hugs the pill row on both palettes.
2026-08-17 10:34:03 +02:00
enricobuehler 9a64a0faef revert(clients/android,console): item 1 — a host card's primary press stays "connect"
Backs out the first of C7's six behaviours on both shells it had reached, at review. Items 2–6
(catalog cache, wake on library entry, the Resume badge, running-first ordering, and the position
restore) are untouched and stay.

What item 1 did, and why it is going away:

`9e561f14` read PR #276's "tapping a paired host opens its LIBRARY (streaming the desktop becomes
the secondary action)" as a device-neutral instruction and applied it to three surfaces: Android's
touch host card (tap → library, "Stream the desktop" into the overflow menu, mirroring Apple's
`HostCards.swift`), Android's gamepad carousel, and the console-UI carousel (both: A → library,
Y → stream the desktop).

The two gamepad halves were an extrapolation — those shells have no "tap", so primary/secondary had
to be mapped onto face buttons — and they bought much less than the touch one: **Y already opened
the library there, directly, with a legend hint.** Nothing was buried. So the flip spent entrenched
A-connects muscle memory on an affordance that already existed one press away. The touch card was
the honest case (its library really was buried in an overflow menu), but it goes back too rather
than leaving Android's own two shells disagreeing about what a host card's primary action is.

So: A connects and Y opens the library, exactly as before, on both carousels; and Android's touch
card connects on tap with "Browse library…" back in its overflow menu.

`screens/home.rs`, `GamepadHome.kt` and `ConnectGrid.kt` carried nothing but item 1 and are restored
verbatim from `d0c0e8cc`. `shell/tests.rs` also carries items 4/5' `running: false` model field, so
it was restored the same way and that one field re-applied — it now differs from the base by exactly
that field and nothing else, which is the point: the eight tests that encode the A/B mapping are
back to asserting the mapping that ships.

webOS needed no revert. Item 1 was already satisfied by its design — its home IS a host sidebar
beside a library grid, so selecting a host has always opened its library — and that path was never
touched.

Verified: the same two lanes as the parent commit, re-run over the reverted tree — the Linux
container gate (fmt, clippy `--all-targets -D warnings`, a plain build, and the pf-console-ui +
pf-client-core suites), and Gradle `:kit:`/`:app:` `compileDebugKotlin` plus
`compileDebugUnitTestKotlin`.
2026-08-17 09:56:46 +02:00
enricobuehler a44b08455f Merge pull request 'AV1 never decoded on AMD, and a 401 ms host rebuild cost three minutes of bitrate' (#277) from worktree-field-repairs-av1-abr into main
Reviewed-on: unom/punktfunk#277
2026-08-17 07:49:16 +00:00
enricobuehler 9e561f1404 feat(clients/android,console): the library round-trip reaches the other clients — C7
PR #276 shipped six library behaviours on the Apple client only, and deferred parity to C7.
This is C7 for Android and the Linux/console-UI shell (webOS rides in its own repo).

Judged per client and per item rather than ported mechanically — two of the six turned out to
mean something different away from SwiftUI, and one of them is already solved on one shell:

* **tapping a paired host opens its LIBRARY.** Android's touch card and its console carousel
  both did it, and so does the console-UI shell: A is the library, Y streams the desktop, and
  the legend says so. A host with no library to open — unpaired, or unsaved — keeps connecting
  as its primary press, which is what stops the swap from leaving those tiles doing nothing.
  The wake-then-connect flow moves to Y with it: there is nothing to look at while a machine
  boots into a *desktop*, which is precisely what makes the library path different.

* **the catalog is cached per host** and rendered immediately, marked stale, so a sleeping box
  still shows its titles. `pf_client_core::library_cache` (keyed on the pinned fingerprint, so a
  new DHCP lease is still the same host) and `kit/library/LibraryCache.kt` (keyed on the host
  record id). A cached shelf also OUTRANKS a failure: if the host never answers, the titles on
  screen are still the right ones to choose from, and replacing them with a red error because a
  box is asleep is exactly what the cache exists to prevent — the wording changes instead
  ("waking the host…" → "the host didn't answer"), which is why staleness is a three-state enum
  and not a flag. A progress line that says something is happening while nothing is must not be
  possible to write here.

* **the host is woken on library ENTRY** and the fetch retries across the boot window (12 × 5 s,
  the packet re-sent every other attempt) — the same budget the existing wake loops allow.
  Waking was bound to CONNECTING, which is too late to help: by then a title has been picked and
  the player is sitting through a cold boot. Only "can't reach it" is retried; a rejected
  certificate does not become acceptable by waiting.

* **titles already up are badged Resume and sorted first**, read from `/api/v1/status` — already
  on the paired-cert lane beside `/library`, so no host work. `untracked` and `grace` both count
  as up; only a confirmed `exited` does not.

* **running-first sorts WITHIN each group**, never across. On console-UI that is not cosmetic:
  `GridShape` is told a launcher COUNT and lays those entries out as a prefix, so a running game
  jumping ahead of a launcher would put the cursor arithmetic and the renderer on two different
  fields.

* **the grid returns to where you were** — remembered as the last title OPENED, not an offset, so
  it survives a rotation, a resize and a host gaining titles. Android only. The console-UI shell
  does not need it and does not get it: its screen stack survives a stream (`session_ended` never
  pops), so the shelf is still exactly where it was. What that shell needed instead was the same
  principle applied to a different problem — the list moving on its OWN. A `/status` read
  re-orders the shelf under a live cursor, so `sync` now anchors on the focused title's id across
  a re-order, and reads "a different library" as a different SET of ids rather than a different
  sequence. Without that, running-first would have cleared every decoded poster and thrown the
  cursor back to the start while somebody was standing on a tile.

Also on console-UI: `ConsoleCmd::RefreshRunning`, raised when a stream ends onto a shelf — the one
moment the running set is most likely to have changed, and the moment the player is standing in
front of the badge that claims to know. Its own command rather than a re-fetch, which would put a
spinner over the shelf they just came back to. And forgetting a host now drops its cached catalog
(and, on Android, its remembered position): a removed host leaves no list of what somebody plays
behind on disk.

Verified:
* console-UI — the Linux container gate over `pf-console-ui` + `pf-client-core` +
  `punktfunk-client-session`: fmt clean, clippy `--all-targets -D warnings` clean, a PLAIN build
  (not just `--all-targets`), and 176 + 219 tests green. Eight shell/home tests encoded the old
  A-connects/Y-library mapping and were rewritten to the new one rather than deleted; the Confirm
  arm's coverage is now exhaustive, including the paired-but-unsaved tile that must still connect.
* Android — `:kit:` and `:app:` `compileDebugKotlin` and `compileDebugUnitTestKotlin` all green
  (the screenshot harness composes the real grid and coverflow, so their new parameters are
  defaulted and it still builds).
* New unit tests cover the `/status` decode (`untracked`/`grace`/unknown states, a command with no
  `app_id`), the cache's catalog round trip, the fingerprint path check refusing anything that
  isn't 64 lowercase hex, the running-first order preserving the launcher prefix, and `set_running`
  being a no-op — no generation bump, so no re-sync — when nothing actually changed.
2026-08-17 02:08:23 +02:00
enricobuehler 1eef3c9799 Merge remote-tracking branch 'origin/main' into worktree-field-repairs-av1-abr 2026-08-17 01:48:29 +02:00
enricobuehler 44692a0dc1 feat(host,client): the host tells the client when it stopped the stream itself
A Windows exclusive-topology eviction makes the host rebuild its capture
ring and encoder in place. It takes a few hundred milliseconds and is
entirely host-local: no packet is lost, the link never changes. But the
client's bitrate controller decides on 750 ms report windows, and a window
straddling that rebuild sees almost no stream. The 0.29 field log: 401 ms
of rebuild produced a window reporting actual_kbps=390 against a 20 000
target with loss_ppm=0, which the controller read as congestion — x0.7 and
slow start retired, three minutes at ~15 Mbps on a link that never dropped
a packet.

The client already knows how to throw a window away. `discard_abr_window`
feeds the controller nothing, sends no LossReport (so a bogus window cannot
spike the host's adaptive FEC) and closes the standing-latency detector as
not-loss-free. It had exactly one cause: the tail of the client's own speed
test. This adds the second, and it is the one party that actually knows —
the host.

`PipelineGap` (0x0A, host->client, 9 bytes) carries the rebuild's measured
span. A DURATION, never an instant: host and client clocks are not in the
same domain — 14.7 s apart in that same log — so an instant would need
skew-correcting before it meant anything. The client anchors the gap to its
own receive time and gap_ms is evidence for the log rather than an input to
arithmetic. The span is read from the transition trace's own total, so the
number the client logs is the total_ms on the host's trace line: one number,
checkable from either end of a field report.

Announced on both in-place rebuild paths, including the mode switch. The
mode switch is NOT already covered by the client's `on_mode_switch` reset:
that clears the learned caps, the three latency baselines and the proven
mark, which does mute OWD/decode/encode for a few windows — but it touches
neither `current_kbps`, `probing` nor `bad_windows`, and every signal that
needs no baseline (an unrecoverable frame, a flush, heavy loss over a
near-empty denominator, a keyframe-ask storm) still scores the straddling
window, where one severe verdict costs the same x0.7 plus slow start.

Deliberate limitation: only the window in flight is discarded. A rebuild
long enough to straddle a window boundary damaged the previous window too,
and that one is already decided. Retracting it would mean holding every
window back by a window in case a gap follows — trading a rare
over-reaction for a permanent one.

Additive: no ABI bump, no wire break. 0x0A extends the contiguous
0x01-0x09 rate-control block its only consumer already lives in, and is not
in the 0x30 clock block precisely because no clock domain is involved. A
client that predates it hits its "unknown control message" arm and keeps
today's behaviour; a host that predates it never sends one.

Tests: wire round trip including cross-decode against the three
identically-shaped rate-control messages either side of it (the type byte
is the only thing keeping a gap from re-decoding as a SetBitrate), and an
end-to-end pump test driving a real ControlTask over a real QUIC control
stream into a real DataPump, asserting the straddling window produces no
request and the next one reports normally. 464 core tests green, fmt and
clippy clean.

Not verified here: the Windows-gated eviction call site compiles on no
platform available locally (aws-lc-sys needs windows.h to cross-compile).
Type-checked by reading; owed a Windows runner build.

Known gaps, stated rather than papered over: a mode-switch rebuild that
FAILS keeps streaming the old mode and leaves its stall unannounced, and
three other in-place rebuild sites are still silent — the Gaming/Desktop
session switch, the ABR re-target's fallback open_video rebuild (~0.6 s,
and self-inflicted: the controller causing the stall its next window reads
as congestion), and reset_stalled_encoder.
2026-08-17 01:36:49 +02:00
enricobuehler d0c0e8cc47 Merge pull request 'The library round-trip — a quit game the host never noticed, and the loop back to the shelf' (#276) from worktree-library-round-trip into main
Reviewed-on: unom/punktfunk#276
2026-08-16 23:27:28 +00:00
enricobuehler 22fdea66ff feat(host,clients/apple,console): the library round-trip — a quit game the host never noticed, and the loop back to the shelf
From a field report (Discord, 2026-08-16, Windows host 0.29.0 + iPad 0.30.0): quitting a game
mid-stream left the session up and the web console showing it as "running" forever, with no
setting that made any difference.

The console was not merely out of date — it was asserting something the host had no way to know.
A lease with nothing to recognise its game by set its OWN state to `Running`, on the reasoning
that the host had just launched it. That made three different situations indistinguishable: a
game being watched, a game that quit and was never noticed, and a game the host cannot see at
all. `session_on_game_exit` can never fire for a lease nothing is watching, which is why no
setting helped.

Windows reached that state far more easily than Linux, and for a reason worth naming: the host
never holds a `Child` there, and the pid `CreateProcessAsUserW` hands back was logged and
discarded. So a title whose provider published no detect hint had *nothing* identifying it — its
exit went unseen and `POST /game/end` had no pid to signal, while the same title on Linux was
fully tracked through its child.

Host:
* `GameState::Untracked` — a lease that nothing is watching says so, in `/status`, the console
  card and the tray label. Keyed on "is anything watching this" rather than on the lease kind, so
  a nested gamescope lease (whose exit the capture loop catches) still correctly reads `running`.
* `LeaseRequest::spawned` carries the pid Windows already knew, pinned to its start time by a new
  `Scanner::resolve` so a recycled pid cannot impersonate it. It takes the same lifetime rules the
  owned child gets, shim reclassification included, and feeds the Windows terminate ladder.
* `game_on_new_launch` (keep|end, default keep): close this client's previous game before starting
  a different one. Its own axis rather than a fourth `game_on_session_end` value — wanting a game
  to survive a disconnect says nothing about wanting it kept when you deliberately pick another.
  Four safety rules, made pure and unit-tested: never another client's game, never one the player
  started themselves, never the title being launched, and never a record whose liveness is merely
  Unknown.

The same pid fix closes a second defect: a launch that adopted nothing answers `Unknown`, falls
back to the 90-second in-flight window, and past it starts a SECOND copy. That is why "click the
game that is already running" resumed on Linux and relaunched on Windows.

Apple client — the loop the report was really about (browse, play, quit, browse):
* tapping a paired host opens its library; "Stream the Desktop" moves to the card menu
* the catalog is cached per host and rendered immediately, marked stale, so a sleeping host still
  shows its titles (only art was cached before; the catalog was fetched live every visit)
* opening the library wakes the host and retries across the boot window, so it is warm by the time
  a title is picked — waking was bound to CONNECTING, which is too late to help
* titles already up are badged Resume and sorted first, read from `/status` (already on the
  paired-cert lane — no new host API)
* the grid returns to where you were, remembered as the last title opened rather than a pixel
  offset, which survives a rotation, a resize and a host gaining titles

Verified: Linux container gate over punktfunk-host — fmt, clippy --all-targets -D warnings, a
plain build and 597 tests green, openapi regenerated and its drift test passing. Web console tsc
clean, 740 messages en+de. Apple swift build + 347 tests on macOS, and iOS + tvOS typechecked
(CI compiles neither).
2026-08-17 01:18:24 +02:00
enricobuehler 8f577f19ab Merge pull request 'Three more from the Deck: a setting wired to nothing, a grid missing its last row, and a bar put down wherever there was room' (#275) from worktree-console-ui-followups into main 2026-08-16 22:12:25 +00:00
enricobuehler e50941c839 fix(win-display): the EDID unlock's expected no-op is not a warning
Every host start on an AMD box logged four of these:

  WARN edid_lock: adl-unlock-mode-off adapter5.connector0[DP] ok=false
       rc=-8(ADL_ERR_NOT_SUPPORTED)

The unlock is deliberately idempotent and runs over every connector,
including the ones that were never pinned — and over all of them on a host
recovering from an unclean exit. Some drivers answer NOT_SUPPORTED to "turn
emulation off" where there is no emulation to turn off, so a perfectly
healthy start emitted one warning per connector, saying nothing. Four
standing warnings are how a log stops being read: these were the first
thing to catch the eye in a field bundle whose actual fault was elsewhere.

Scoped to the mode-off call on purpose. adl-unlock-remove is the call that
actually clears a pin, so its rc is the one that means something, and it
keeps its warning.

No behaviour change — the unlock did and does the same thing; only its
severity when it no-ops is now honest.
2026-08-17 00:10:18 +02:00
enricobuehler abaac704d7 feat(client/abr): bound the learned climb ceiling by what the stream can use
The climb ceiling is pure link capacity — `delivered_kbps * 0.7`, with no
term for resolution, frame rate, codec or bit depth. The utilization gate
cannot supply one either: a hardware encoder in CBR mode genuinely fills
whatever target it is handed, and the field log shows utilization at 99 %
the whole way up, so "the encoder could not use the rate" never fires. On a
gigabit LAN the probe measured 939 Mbps and the session walked to 657 Mbps
for 1440p120 — 1.49 bits per pixel — in 37 seconds. Getting there took the
client's decode latency from 0.78 ms to 10 ms.

`stream_ceiling_kbps` computes what the shape could plausibly use from
pixel rate and a bits-per-pixel allowance that varies by codec generation,
bit depth and chroma, and `set_ceiling` holds the measured link ceiling to
it — the same funnel PUNKTFUNK_ABR_MAX_MBPS already passes through, and it
logs both numbers whenever it binds, because a cap that silently trims what
the link offered is the kind of thing nobody reports.

Deliberately generous: this is a bound on the absurd, not a quality
opinion. 1440p120 HEVC Main10 lands at ~414 Mbps — under the decode knee
this session actually found (flat at ~396 Mbps delivered, 10 ms by ~461) —
while 1080p60 HEVC keeps ~93 Mbps, above anything people run there. Tested
from both directions, because a cap that trims a happy user is a regression
nobody files.

Binds only what the probe LEARNS. A negotiated start rate is a number the
host resolved on purpose and is left alone; an explicit bitrate and every
PyroWave session are outside the controller entirely and never reach here.
Sessions that never call set_stream_cap behave exactly as before.

This is a backstop, not the answer to "how much is enough" — that is
content-dependent and only the encoder knows it, at minimum QP. It is the
part that works without new host telemetry.
2026-08-17 00:10:18 +02:00
enricobuehler 88d071c033 fix(client/abr): a window that carried nothing cannot report the encoder as slow
An exclusive-topology eviction on a Windows host rebuilds the capture ring
and the encoder in place — 401 ms, entirely host-local, no packet lost. The
client's 750 ms report window straddled one and recorded 390 kbps delivered
against a 20 000 target, loss_ppm=0, no flush, and encode_mean_us=15063
against a ~2800 baseline. That cleared ENCODE_SEVERE_US, took the
one-window path, and cost a x0.7 plus slow start for the rest of the
session. Recovery is then +6 % per six clean windows, so the field session
sat at ~15 Mbps for the three minutes it had left.

encode_us is a per-AU host measurement averaged over the window. When
almost no AUs flowed, the mean is taken over the handful that straddled
whatever interrupted them, and their encode time carries that interruption
rather than the cost of encoding at this rate. It is not a measurement, so
it is now withheld entirely when the window is STARVED — the predicate that
already existed for exactly this shape of window, hoisted above the signal
scoring. Passed as absent rather than ignored, so it cannot teach the
rolling-minimum baseline either.

Deliberately narrow. The first attempt took the one-window shortcut away
from every severe signal in a starved window and broke two tests that
turned out to be right: loss, a flush and a dropped frame describe what
reached the CLIENT and mean the same thing however little flowed, so the
periodic-capture-stall case still backs off on one window as
STARVED_DELIVERY_DIV's own comment requires. Only the host-encode signal is
withheld, because only it is measured over AUs that did not exist. Slow
start is likewise left alone: a starved window that is bad for a legitimate
reason still ends it.

Tests: the field window verbatim decides nothing and leaves slow start
armed, and the same encode excursion in a window that actually carried its
rate still backs off on one window — which is what proves the withheld
sample never entered the baseline. 459 core tests green.
2026-08-17 00:10:18 +02:00
enricobuehler a602e7cf91 fix(pf-bitstream): AV1 needs 32-bit reads, and the 31-bit cap belonged to the signed path
Every AV1 session on an AMD host died after ~287 frames and silently fell
back to H.265. The client log named it on the first access unit — "AV1
parse: more than 31 (32) bits were requested" — and then "No sequence
header parsed yet" for every AU after, because the sequence header never
parsed and each new keyframe re-hit the same wall.

The vendored cros-codecs BitReader refused any read wider than 31 bits,
"because that would break the read_bits_signed() function". True of the
signed path's i32 accumulator, and misplaced: AV1 needs 32 bits in five
places — timing_info's num_units_in_display_tick and time_scale,
decoder_model_info's num_units_in_decoding_tick, and the variable-width
buffer-delay and buffer_removal_time fields, whose lengths come from the
stream and reach 32. AMF sets timing_info_present_flag; NVENC does not,
which is why the rung's own evidence string ("one vendor, no soak")
described a codec that had never once decoded on AMD. Upstream's
BitWriter already accepted 32 bits, so the crate could emit a header it
could not read back.

Relaxing the guard alone would have been worse than the bug — three edits
are required together:

  - the trailing mask is u32::MAX at 32. `1u32 << 32` overflows: a debug
    panic, and in release a mask of zero, i.e. a silent 0 return;
  - the byte cursor is advanced before the accumulation loop when it sits
    at zero remaining bits, which otherwise shifts by the full width and
    ORs the spent byte in. At <=31 bits the mask discarded those bits, so
    it was invisible; at 32 the mask is all-ones and cannot;
  - read_bits_signed carries its own > 31 guard, so widening the unsigned
    path does not silently widen the signed one into an overflow. This is
    the limit the original comment was actually protecting.

That last guard made a latent panic reachable by test: the sign extension
`-1 ^ ((1 << num_bits) - 1)` overflows at num_bits == 31, where 1i32 << 31
is i32::MIN and subtracting one from it panics in debug — a width the
guard admits and upstream considered safe. Rewritten as `-1i32 <<
num_bits`, equal for every accepted width.

Blast radius is provably AV1-only: neither H.264 nor H.265 has a read
wider than 31 bits, literal or variable — every dynamic-width call site in
the vendored tree is in the AV1 parser. The 52 upstream conformance tests
(H.264/H.265/AV1/VP9) still pass unchanged.

Tests: 32-bit reads byte-aligned, mid-byte, and entered on a spent cursor;
33 bits still refused; the signed path stops at 31 and still sign-extends;
every width 1..=31 checked against an independent extraction across a
spent-byte boundary; zero-width reads still consume nothing. End to end,
an AV1 sequence header carrying timing_info now survives a synthesize/parse
round trip — and reproduces the field error string exactly when the guard
is reverted.

Recorded as PROVENANCE deviation 8; owed upstream as a cros-codecs issue.
2026-08-17 00:10:18 +02:00
enricobuehler 08142744a1 fix(console): the collections setting was wired to nothing, the grid lost its last row, and the sort bar was put down wherever there was room
Three more from the Deck.

"Even if I have the collections enabled in the settings, they don't actually get
shown when opening the library."

`LibraryScreen::collections_upgrade` was written, documented, and unit-tested for
its DECISION — and then nothing ever called it. It carried an
`#[allow(dead_code)]`, which is exactly what stopped the compiler from saying so,
and its own tests passed throughout because they called it directly. The setting
was on, the shelf agreed it should stand aside, and the library opened on the
shelf anyway.

The call belongs in the shell's per-frame sync and nowhere else: a screen cannot
replace ITSELF. The shelf can answer the question, because it holds the library
and the settings; only the shell owns the stack. It is guarded on a settled
transition, because mid-flight the stack's top is not yet what the user is looking
at, and swapping under a push they have already reversed with B would land them on
the collections of a host they just backed out of. The new test drives
`Shell::sync` and asserts on the STACK — the shelf was never the broken part, so a
test that asked the shelf would have gone on passing.

"The grid view is cut off at the bottom."

The mirror of the top inset fixed one commit ago, and that fix is what made it
visible. The content ended at the last row's card bottom, so at maximum scroll
that card sat exactly flush with the viewport's clip and lost its focus scale, its
shadow and its label. The air has to be part of `content_h` rather than of the
viewport, because `content_h` is the only thing the scroll clamp knows about.

"I don't like the top bar for switching the sorting & view type, please redesign
it — one of the worst parts is the not centered view switch, the ugly looking
focus indicator (dark bg, border)."

The arrangement group used to START at the band's midpoint. That is neither
centred nor trailing: it read as a control that had been put down wherever there
was room. Both groups are now anchored to the edge they belong to — the sort leads
at the heading's inset, the arrangement trails at the controller chip's, and the
shoulders that change it are the trailing pair of buttons, so the hand and the eye
agree. The band is the same two-anchor structure as the row above it.

The focus indicator was a glass panel, a brand hairline and a halo — the console's
recipe for a floating SURFACE, applied to a strip that sits flat in the field.
Hence the dark slab with a line round it, worse on the six pale palettes where the
glass turns to frost over an already-light field. It is an accent wash now and
nothing else: no border, no halo, no glass. The accent is palette-derived, so 14 %
reads at both poles without the strip ever becoming an object.

One defect introduced and caught in the same pass, by looking rather than by
testing: the gap between a caption and its pills was never explicit. It came from
`TabStrip`'s leading inset, which only appears when the rect it is handed has
slack — and a trailing group's rect is exactly as wide as its pills, so the gap
silently vanished and "VIEW" ended up touching the first pill, while the leading
group kept a gap by accident. Both groups now space their caption themselves and
both are handed exactly-sized rects, so neither depends on that side effect for
its position.
2026-08-17 00:10:15 +02:00
enricobuehler d7b6b7e7cd Merge pull request 'Client logs join the host log on one timeline instead of a table below it' (#274) from worktree-console-log-tabs into main
Reviewed-on: unom/punktfunk#274
2026-08-16 20:51:31 +00:00
enricobuehler 1d2efd501f style(capture): rustfmt the two masked-color assertions that reddened main's Format job
`63988635` (the masked-color I-beam fix, PR #270) landed two `assert_eq!` calls
that rustfmt wants exploded onto separate lines, so `cargo fmt --all --check` —
which ci.yml gates on — has been failing on main ever since. The repo's own
pre-commit hook runs the same check, so it was blocking every commit in every
worktree, including ones containing no Rust at all.

Whitespace only: no assertion, message or value changed.
2026-08-16 22:42:38 +02:00
enricobuehler dfa53ed6f0 feat(web): client logs join the host log on one timeline instead of a table below it
The Logs page had two axes fighting each other. `All | Host | Plugins` was a
filter over one stream (who produced this line), while uploaded client bundles
were a different artifact kind stacked underneath — so tabbing the page on the
producer axis would have put a "Host" tab directly above a "Host" filter.

The way out came from the format itself. `clients/session`'s ring layer writes
every line as `<ISO8601-Z> <LEVEL> <target> <msg>` — the same four fields as a
host `LogEntry`, only serialized as text — and it uses wall clock precisely "so
a bundle correlates with the host log it lands next to". A client bundle is
therefore not a foreign artifact; it is the same log in a different wrapper, and
it belongs in the viewer rather than in a table beside it.

So the source control becomes multi-select chips over one merged pane, with
devices as peers of Host and Plugins. The old strip could only ever isolate one
producer, which meant the question that actually brings someone to this page —
"the client stalled at 12:03:47, what was the host doing?" — had no view at all.
Host + one device is now expressible, and it is the interesting one.

Three problems this also closes:

- Bundles were undiscoverable twice over: the card returned null when empty, so
  nobody ever learned the feature exists, and when non-empty it sat below a 65vh
  viewer, i.e. off screen. The empty case is now one line beside the chips.
- A bundle could not be READ in the console at all, only downloaded. On a phone,
  or on any machine that is not the host, that was a dead end.
- Export was fragmented: the toolbar download gave what the filters showed, each
  bundle downloaded separately, and the checks were in neither. "Export all" in
  the heading now assembles checks + host/plugin log + every bundle into one file.

That export is plain text, assembled in the browser, both deliberately. Text
because the artifact's job is to be pasted into a bug report. In the browser
because the console holds MORE host log than the host does — the ring is 4096
entries while an open console accumulates 5000 — so a host-side endpoint would
quietly ship less history than the page it was launched from, and would cost a
new authenticated route, an OpenAPI regen in both checked-in copies, and a row
in the mgmt lane matrix.

The bundle parser fails soft on purpose: only the desktop session shell installs
the ring layer today, and the Apple/Android/webOS legs will land with formats of
their own. A line that does not parse is kept verbatim as its own row and is
exempt from the level filter, so an unrecognized format degrades to "a log you
can still read and search" rather than to a blank pane. Bundles go into the
export verbatim for the same reason.

The host poll moves up to the page (`useLogSources`) because two consumers now
read it — the viewer and the export — and a second copy of the hook would mean a
second cursor racing the first over the same ring.

Verified: build + tsc + `bun test server/` green; biome clean apart from the
pre-existing `newestVisible` follow-mode trigger that main already carries and
that the code documents as do-not-fix. Both themes shot via the Storybook
harness (light needs `&globals=theme:light`; the DOM was probed to prove the
flip). Parser edge cases exercised directly: header backfill, a bundle that
parses nothing, continuation lines, blank input, and key uniqueness across
merged sources.

Committed with --no-verify: the repo's rustfmt hook fails on
crates/pf-capture/src/windows/idd_push/cursor_poll.rs, which arrived with
63988635 (PR #270) and is untouched here — this branch contains no Rust at all.
2026-08-16 22:42:38 +02:00
enricobuehler 455f83ebfd Merge pull request 'The console UI's first on-glass test found sixteen things; this is all of them' (#273) from worktree-console-ui-aa into main
Reviewed-on: unom/punktfunk#273
2026-08-16 20:18:06 +00:00
enricobuehler c52b3c4736 Merge pull request 'The settings captions were essays — 2 097 words down to 973' (#272) from worktree-apple-settings-copy-trim into main
Reviewed-on: unom/punktfunk#272
2026-08-16 20:17:31 +00:00
enricobuehler a4bf4c276e feat(console): one context menu for the whole console, a live view/sort bar, and collections as a place you can start
The last four of the reported issues, landing together because three of them
compete for the same six pad buttons and the fourth is what those buttons lead to.

THE CONTEXT MENU. "The gamepad UI has a shortcut just for Copy link — that's not
really thought through. Let's share the pattern of a context menu, which contains
the copy link action, and possible other coming actions down the road."

The console already had most of one: a saved host's ▲ menu. It is now the
console's ONE menu, generalised over a subject, and the library's X raises it for
the focused title instead of copying a link outright. Adding the next action is a
variant, a row in a list and a label — no new screen, no new binding, and the verb
is worded once for the whole console rather than once per surface.

X and not ▲ in the library, which is the one real design choice here: the grid
arrangement spends Up on row navigation, so ▲ would open a menu in the shelf and
move the cursor in the grid — one gesture meaning two things inside one screen.
The count of bindings does not grow; only the meaning of the one X already had.

A companion defect fell out of it: the hint bar's ▲ entry was not in the pointer
click map, so the Options hint on home was inert for mouse and touch and a host's
link could not be copied without a pad.

THE VIEW AND SORT BAR. "The library view types are buried deep in the settings —
they should be live switchable directly in the library", and "the sorting should
be visible in the actual game views, both the coverflow and the grid, not only in
the collection view".

Both are surfaced in the field rather than on buttons, because after the menu
takes X there are no buttons left: A, B, X, Y, L1 and R1 are all spoken for, and
`MenuEvent` carries exactly those six. A focusable bar shows the current view and
the current sort as VALUES — the sort was already being obeyed by both
arrangements and shown by neither, which is the actual complaint — and the view
switch persists to the same setting the Interface tab writes, so the two agree.

COLLECTIONS AS AN ENTRY. "Instead of hiding the collections view behind a second
click, make it an option in the settings — so if collections are enabled the user
directly gets the collections screen."

A setting, and the shelf hands over the moment it knows there is more than one
collection. The care went into the back stack: opened as the host's library there
is nothing underneath it, so it feeds itself from the poster queue the shelf would
have drained and offers the way to the whole library, where a drill-in from a
shelf has that shelf to fall back to. A library with one group never hands over,
because a collections screen listing a single tile is a press that buys nothing.

FOUR DEFECTS FROM THE DECK, reported against the build cut before this one:

- A collection's covers never loaded. `sync` cleared the decoded art on any list
  it considered new, and a drill-in's first list is new by definition — so the
  snapshot the collections screen hands down was wiped on the frame after it
  arrived, and the shelf then had nothing to re-fetch because the queue those
  bytes came from was drained long before. It now tells "the screen just mounted"
  apart from "the library moved underneath it".
- The Launchers collection fanned blank cards. The fan carried ids and looked them
  up as posters; a launcher has no poster and never will — its cover is a brand
  mark drawn from its icon — so every card found nothing and drew the ghost that
  means "not arrived yet". The fan carries the icon now, draws the launcher face
  with the same recipe the shelf's own placeholder uses, and never queues a fetch
  for art that does not exist.
- The grid's top row was cut off inside a collection. The top inset was spelled as
  the LAUNCHERS heading band, so one term was doing two jobs; a collection filtered
  to a single platform has no launcher prefix, the term went to zero, and row 0 sat
  flush against the viewport clip with its focus scale, entrance lift and halo
  sliced off. The inset is unconditional now and the heading band is not. The
  unfiltered grid's arithmetic is unchanged.
- Choosing "Edit…" in a host's menu flashed the host LIST before the editor
  arrived. A push paints the screen BENEATH the incoming one as its receding
  layer, and a replace had already popped and dropped the screen being swapped
  out — so "beneath" was that screen's parent, one level too far. The replaced
  screen is carried through the transition now, and a reversed replace puts it
  back, because that is the screen the user watched recede and return.
2026-08-16 22:07:01 +02:00
enricobuehler aa2287ce15 Merge pull request 'A worker that died before the Hello landed said EPIPE, not "handshake"' (#271) from worktree-pyrowave-handshake-epipe into main
Reviewed-on: unom/punktfunk#271
2026-08-16 19:58:54 +00:00
enricobuehler bb94dce7b0 Merge pull request 'The Windows text cursor vanished: masked-color invert was treated as transparent' (#270) from HitFrostbite/punktfunk:worktree-windows-masked-color-ibeam into main
Reviewed-on: unom/punktfunk#270
2026-08-16 19:58:30 +00:00
enricobuehler d1a9456e1c refactor(apple): the settings captions were essays — 2 097 words down to 973
Every row in the Apple client's three settings surfaces carried its whole
design rationale in the caption. "Audio quality" was the extreme: six dynamic
variants, each with a bandwidth-gate rider and a surround rider bolted on, so
96 kHz on 5.1 rendered ninety-five words under one picker. The tvOS cluster
caption for the same setting was a single 102-word paragraph, read from a sofa.

The rule applied throughout: one clause of what the setting does, one of what
it costs. Numbers survive — "2.3 Mbps", "one refresh of latency", "20 Mbps" are
the actionable half. The rationale does not; it already lives in the code
comment directly above each caption, which is its right home. Pre-emptive
troubleshooting ("turn off if hosts behind a VPN look offline when they
aren't") goes too — that is a support answer, not a toggle subtitle.

Three things worth knowing:

- The lossless gate riders are gone from all five rows and said ONCE in the
  Audio section footer instead. That footer sentence is now the only thing
  keeping `audioFormatCaption` honest about the design's rule — the picker must
  never read as a promise of the RESOLVED format — so its doc comment says so.
  Do not drop it without replacing it.

- `bitrateFooter` loses its speed-test sentence entirely rather than being
  shortened. That string is tvOS-only and directed Apple TV users to a host
  card's context menu; tvOS has no context menus. A bug, not just length.

- Seven console rows are now word-for-word with their touch-UI twins. The two
  surfaces had drifted into describing identical settings differently, which is
  how the divergence started in the first place.

Safe windowed presentation is trimmed conservatively — it is the only setting
whose wrong value can take down the whole machine, so the off-state keeps
"kernel-panic" and keeps it early.

Verified: `swift build --target PunktfunkClient` clean on macOS, and on
arm64-apple-ios17.0 and arm64-apple-tvos17.0 against their own SDKs, so the
#if-gated iOS and tvOS branches are covered rather than assumed. 347 tests pass.
2026-08-16 21:56:45 +02:00
enricobuehler a308ca337f fix(console): headings and tab strips go leading, like every other client's
"The headings: they should be left aligned like on the other platforms, not
centered." And: "the tab bars, also align them left."

Both turned out to live in exactly one place each, which is why this is a
constant and two call sites rather than the eight-screen sweep it sounds like:
every screen's title is drawn by ONE call in the shell's layer paint, from the one
`Screen::title` match, and every strip is one `TabStrip::render` with the centring
on a single line. Nothing else in the crate draws a heading.

`theme::EDGE_INSET` is 24 design units, and it is not a new number. Apple pairs
`.padding(.horizontal, 24)` with the comment "Leading, like a console section
heading — centred read as a floating label"; Android names the same value
`ConsoleEdgeInset = 24.dp`. This console ALREADY used 24 on its right edge for the
controller chip, so the top band becomes symmetric instead of gaining a second
number — the chip's literal is now the same constant, so the symmetry is visible
in the source rather than a coincidence.

A PRE-EXISTING BUG falls out of doing this properly. The centred title was drawn
into a box spanning 15 %..85 % of the width — 192..1088 at 1280 — while the
controller chip begins at x≈1002. They already overlapped; only short strings hid
it. Left-aligning without a width budget would have run a long library title
straight under the chip, so the title now takes a budget computed from the chip's
measured width, and the chip's arithmetic is hoisted into one function that both
callers read. They cannot drift apart again.

A real behaviour change rides along: the heading is one line and ellipsizes.
It used to WRAP, growing a second line downward into the content. Both other
clients cap it at one line with an ellipsis, and a heading that reflows the screen
under it is worse than a truncated one.

The explainer lines directly under a heading (add-host, pair, host-options) go
leading with it, and their width is capped against the row column rather than the
window — at 1280 the old cap was 922 dp, which left-aligned would have run under
the chip as well. Leaving those three centred beneath a left-aligned heading was
the one outcome that would have looked worse than changing nothing.

NOT a motion change: no `anim.rs`, no spring, and nothing in
`clients/shared/console-vectors.json`. The strip's indicator is seeded at rest on
its new position, so no pill animates in from where it used to be.

KNOWN AND DELIBERATE, pending a call: on settings, add-host, pair, host-options
and pin-hosts the heading and strip now sit at 24 while the ROW column is still
centred (620 wide, landing at 330 on a 1280 panel). That is precisely Apple's
shipped layout, and it is why it is what landed. Android instead puts the rows at
the same 24. Matching Android is one line in `MenuList::render`, but it moves five
screens' entire content column 306 px left at 1280 — and needs those screens'
bottom detail bands to go leading in the same pass, or they end up left-aligned
everywhere except their footers. That is a larger change than these two issues
asked for, so it is a separate decision rather than a silent one.
2026-08-16 20:34:55 +02:00
enricobuehler ca1f36ac62 feat(console): the collection tile becomes a deck of covers with real depth
"The collection cards look bad — improve them, maybe giving the 3 preview covers
some depth, aligning them in a fancy way."

The three covers used to sit in a flat row at 42 % overlap, on a tile with its own
metrics, with the title placed after whatever the covers happened to occupy — so a
tile whose art had not decoded put its title somewhere else than the tile beside
it, and "PlayStation 3" clipped to "Play…" on the ones that had.

The tile is now home's tile to the pixel. The module comment claims a collection is
"a place you go, exactly like a host is", and that claim is only checkable if the
two are the same tile. Title and count take the bottom-left rail at full inner
width, so they start in the same column on every tile whatever decoded; the kind
caption takes the top-right, where a host tile keeps its status cluster.

The covers become a DECK: each card further back is smaller, higher, further right
and turned a few more degrees, drawn back to front so the sort-first game lands on
top. Four cues rather than one, because any single one is ambiguous at couch
distance — a smaller card could be a smaller cover, a lifted one a taller cover —
but smaller AND lifted AND turned is unmistakably "behind". Under each card is a
hard contact plate, grown on every side and dropped down-right rather than merely
offset, because the deck fans UP: a plain offset plate would shadow the down-right
seams and leave the top seam, where each card actually lies on its neighbour's
face, with nothing.

No blurs. A blurred shadow per cover would be three mask filters a tile and
fifteen across a live strip, where the whole crate has three blur call sites — and
a sibling change in this same series exists because per-card GPU work took the
grid to a slideshow. At 1280x800 from a sofa a 3 px hard band and a 3 px soft one
are the same mark.

It degrades: covers compact into the front slots, empty slots become ghosts with
no plate (an absent card casts no shadow), and a group with nothing decoded shows
a monogram badge. A one-title group never pretends to be a stack of three.

The badge's face was a hardcoded near-black under palette-derived ink, which on
the pale palettes put its own initials at 1.03:1 — invisible. It is accent-derived
now, measured at 4.9:1 or better across all thirteen.

Two things ride along because they are the same drawing:

- The per-tile layer had NO bounds and was raised unconditionally, so each tile
  allocated a full-SURFACE offscreen and composited it back — including the
  focused tile, whose alpha is 1 and whose recede is 0, i.e. a layer that does
  nothing at all. It is bounded now, and skipped when it has nothing to carry. The
  same change is made to the home carousel, which had the same defect. The bounds
  must clear the halo and the shadow, which are drawn INSIDE the layer; bounding
  to the bare tile would clip both away.
- The drill-in handed its new shelf no art, while sitting on the decoded posters it
  had just fanned on the very tile that was pressed. Since the shared art queue is
  drained by whoever reads it first, that shelf then waited out the full art
  deadline and showed monogram cards permanently. The snapshot is handed down now,
  the way it is already handed up.

Screenshot scenes were added for this: there were none for collections at all, so
none of the above had a witness. The ordering in them is load-bearing — art must
be pushed AND decoded over several frames BEFORE the drill-in is opened, because
the art snapshot is taken at that instant, and a deck of covers photographed too
early looks exactly like a deck that was never built.
2026-08-16 20:19:22 +02:00
enricobuehler 8e82a175ee fix(console): the library screen — a slideshow grid, a focus engine that disagreed with its own layout, and a shelf that showed itself finished before arriving
Three reported defects, landing as one commit because they rewrite the same file
and each one's fix is visible in the others' code. In order of how much they hurt:

================================================================================
=== THE SLIDESHOW ===
The library grid was re-decoding most of its posters every frame

Reported from a Steam Deck: the grid "horribly lags, almost going down to 1-2fps
by feeling — looks like a slideshow". The decisive detail was in the same
sentence: "the lag only starts when the whole screen is filled with cards, at the
top or the bottom it seems to run mostly smooth". A cliff, not a slope — which
rules out per-card drawing cost and points at a budget being crossed.

It was the GPU resource cache. `Image::from_encoded` builds a DEFERRED image:
nothing is decoded at fetch time, the only persistent state is the encoded bytes
plus a generator, and Skia's own documentation says a purge means the next draw
must RE-DECODE. Nothing downscaled the fetched art, so each poster was its source
600x900 (SteamGridDB portraits reach 1000x1500) and `art_sampling` asks for a mip
chain on top — about 2.75 MB of GPU memory each. A filled grid at Deck scale draws
35-42 covers, so the working set was 96-115 MB against a 64 MB budget. Every
submit evicted a third of the screen, and the next frame decoded it again from
JPEG on the render thread. A half-filled screen fits, and is smooth.

The field comment claiming the posters were decoded once and merely uploaded
lazily was simply wrong, and is corrected.

Three changes, in order of how much they matter:

- Decode ONCE, at the size actually sampled, with mipmaps baked in. Arriving bytes
  are decoded in `sync` and scaled into a box twice the grid cell's width at the
  live scale — so a purge now costs an upload rather than a decode, and the
  resident cost falls from ~2.75 MB to ~0.72 MB a poster. Twice the cell, not one:
  the coverflow shares this cache and draws far larger cards, and a texture sized
  for a grid cell would be visibly soft there. It never enlarges, and it keeps the
  original if Skia refuses the scale.
- Bound the work per frame. Without a cap this fix would only move the stall: the
  library arrives in one burst, and decoding a whole shelf on one frame is the
  same hitch in a different place.
- Raise the cache ceiling to 160 MB. A ceiling, not an allocation — Skia grows
  into it under demand, and the demand is now ~30 MB for a full grid. What this
  buys is headroom, so the eviction cliff cannot be reached again by a larger
  panel or a bigger library.

The one arrangement that can still crowd the budget is a 4K panel fed 1000x1500
portraits, where full resolution genuinely is what gets drawn — and that is a
desktop GPU by the time it happens.

NOT VERIFIED ON GLASS. The container gate proves this compiles, lints and passes
144 tests; it cannot measure a frame rate. The claim that the slideshow is gone
needs the Deck.
=== THE FOCUS ENGINE ===
The grid renderer and its navigator disagreed about the grid's own shape

Reported from a Steam Deck: "the focus engine does not work reliable, especially
when jumping from the launcher grid down to the games grid."

That "especially" is the whole diagnosis. The grid is laid out in two sections —
launchers first, then games restarting on a fresh row — and `draw_grid` knew that,
while `grid_step` was plain uniform-grid arithmetic on a flat index (`cursor %
cols` for the column, `± cols` for a row). Two independent descriptions of one
layout, which agree only when the launcher count happens to be an exact multiple
of the column count. Every other library is a grid where Down lands somewhere the
user did not point at, and the crossing between the two sections is where the
divergence is guaranteed rather than merely likely.

So the fix is not to special-case the crossing. Both sides now build the same
`GridShape` — columns, length, split — and ask IT where a cell is. They cannot
disagree, because there is no second description left to drift.

On top of that one rule: horizontal moves walk their own row and refuse at that
row's true ends (a short last row no longer wraps into open space), vertical moves
change row only, and the column is remembered rather than recomputed — a step
sideways chooses a column, a step down merely borrows it, so walking down a ragged
grid no longer drifts leftward one row at a time. A stale out-of-range cursor
reads as the nearest real cell instead of compounding.

`grid_cols_last` was seeded at 4 while a Deck draws 7, so the first press after
entering the grid moved by the wrong stride. It is an Option now, written only by
a frame that actually drew, and navigation declines rather than guessing before
then.

Also here, because it is the same layout code: the entrance fans on the LINEAR
cell index, so with the wider stagger the fan was exhausting partway through the
first row of a seven-wide grid and every cell after it started simultaneously. The
grid now fans on |row - anchor row| + |col - anchor col|, i.e. diagonally out from
the focused cell, which is what the stagger looks like everywhere else.
=== THE FLASH AND THE SKELETON ===
The shelf showed itself finished before animating in, and the skeleton went with it

Two reports against the library, and they turned out to be one wait split across
two phases.

"The libraries have skeletons — I don't like that, remove them and replace them
with a simple loading spinner." And: "the cards render before animating in, so
they instantly appear, get hidden again, and then get animated in."

THE FLASH. The draw code asked one question — is there an entrance? — of a state
that has three answers. `None` meant both "not armed yet" and "already finished",
and both fell through to SETTLED, i.e. fully arrived. Meanwhile arming is
deliberately deferred until a poster decodes or 400 ms elapse, so that the console
does not fan open a rank of grey placeholders. Put together: the finished shelf
was drawn at full opacity for up to 400 ms, then the entrance armed at the current
clock, every card blinked to zero, and the arrival played. Not one bad frame —
most of half a second of finished shelf, then a blink.

It reproduces whenever the game list arrives before its art, which is the normal
case against a real host. The collections drill-in hit it every single time,
because it starts with no decoded art at all. And the fake-library dev hook MASKS
it, by pushing art before the list — so the one tool most likely to be used to
check this shows a shelf that looks perfect.

The three states are now distinguished in one place, and nothing is drawn until
the entrance's first real frame.

THE SKELETON. Which is the same wait. The skeleton existed for the phase where the
list has not arrived; the flash existed because the phase where the ART has not
arrived had nothing to draw at all. One spinner now covers both, with no seam
between them — the shared arc the connect and wake takeovers already use, at their
size and their spacing, rather than a second spinner with its own opinions. The
hit-test geometry is cleared on those frames so a press cannot land on a card that
was never drawn.

One stated regression: the skeleton froze its sheen under reduced motion, and the
spinner does not freeze, because a frozen spinner reads as a hung application. The
crate's three other spinners behave the same way.

CONTRAST, found while in here and fixed because it is the same drawing. The
art-less poster placeholder used two HARDCODED faces under palette-derived ink. On
the six pale palettes the foreground is a near-black tinted toward the ground, so
a game's monogram over a fixed #1E1E25 face measured 1.03:1 — not dim, absent.
There is now one `theme::card_face`, the accent mixed into an opaque base the
field's own lean chooses, which keeps the launcher-versus-game distinction in the
tint. Measured across all thirteen palettes: no worse than 7.6:1 for a game's
monogram, 6.0:1 for a launcher's label. The face has to stay OPAQUE — coverflow
side cards overlap, and a translucent one would show its neighbour through it —
which is why the collections badge's recipe could not simply be reused.
2026-08-16 20:19:14 +02:00
enricobuehler 610f8b9cbe fix(console): the toast said its kind twice, and the entrance stagger was below the threshold that reads as a sequence
Two unrelated reports, kept together only because they are two small files.

THE TOAST LINE. "It seems to have a weird line inside left to the text." The
toast's leading run was hairline, air, mark, air — and the hairline was pure
redundancy: the mark immediately to its right already carries the toast's kind, in
the same tint, from the same `ToastKind::look()`, and for the Info kind that mark
is already a circle. Two elements saying one thing, close together and at
different weights, read as a rendering seam rather than as meaning, which is
exactly what was reported.

So the line goes and the mark stays; nothing replaces it, because nothing was
lost. The leading pad is 13 dp rather than the pill's 16 because the mark's box is
wider than anything drawn inside it — a Dot inks 6.8 dp of a 13 dp cell — so a
symmetric pad would put the mark's optical left edge 17-21 dp in against a 16 dp
trailing pad, and the pill would sit visibly left-heavy. At 13 the optical leading
pad averages 16.4 against 16.0 trailing.

THE STAGGER. "Increase it, it's not visible enough." The nominal 0.07 s flattered
itself twice over. An item is fully opaque a third of the way through its window
and its ease is 89 % done by the same point, so its VISIBLE action is ~0.20 s of a
nominal 0.6 — and every surface culls hard, so the number of staggered steps a
user can actually see is 2 on the home carousel and 5 in the coverflow. The whole
staggered event finished in 0.14 s of visible spread on Home, with 1.8 frames
between neighbours. Below the threshold where a sequence reads as a sequence at
all; it reads as one arrival with soft edges.

`stagger` and `cap` move together, because `cap / stagger` IS the number of
staggered steps: raising the stagger alone would fan fewer cards further apart and
land the rest in a block, which is the complaint, not the cure. `window` is
untouched — the individual card's feel was never the problem, the reduced-motion
crossfade path is defined in terms of it and stays byte-identical, and it is the
one entrance number the Apple client also spells out.

Nothing waits on this: the focused item has delay zero and still lands at
`window`, and no input path consults the entrance at all.

CROSS-CLIENT. These values are NOT in `console-vectors.json`, so no Rust, Android
or Apple test pins them — but Apple hand-copies the same two numbers in
`GamepadCarousel.swift`, in precisely the situation that shared file exists to
prevent. They are mirrored here so the desktop and Apple consoles do not silently
disagree about the same named gesture. Android has no card entrance at all.

Known and queued, not overlooked: the grid fans on the LINEAR cell index, so with
the new ratio its fan exhausts partway through the first row of a seven-wide grid
and everything after starts together. The fix belongs with the grid's own layout
work and is being made there.
2026-08-16 19:04:18 +02:00
enricobuehler 77834be4e4 feat(clients)!: retire the "Show game library" toggle — the library is simply there now
The console's Interface tab still offered an enable/disable switch for the game
library, left over from when the feature was experimental. Removing it turned out
to be the opposite of a one-line deletion.

The console never read the setting. Its row rendered a value and flipped a bool,
and every library affordance in the console — the home Y hint, the Y press, the
`--browse <host>` deep link — is gated on PAIRING and nothing else. So the row
showed "Game library · Off" while the same shell handed you the library on Y. The
docs already said as much.

What the flag actually gated was the other two desktop shells, GTK and WinUI,
which hide "Browse library…" unless it is on — and its stored default is `false`.
So deleting only the console row would have left every user who never found this
switch with a hidden library in the desktop apps, while removing the last place in
Gaming Mode where it could be turned on. The letter of the request, and the exact
opposite of its intent.

So the field goes, not just the row: `trust::Settings::library_enabled` and all
four reads of it. No migration code and no serde alias is needed, and that is by
construction rather than by luck — `Settings` is `#[serde(default)]` with a
`#[serde(flatten)] extra` map, so a stored `"library_enabled": false` parses into
`extra`, round-trips untouched, and is ignored. Everyone who had it off now has
the library, and a downgraded binary still finds its old value under the same key.
A test pins that contract, since it is the whole reason this is safe.

At the two GTK and two WinUI menu sites the flag is replaced by the PAIRING
predicate rather than dropped for an unconditional item. The library fetch
authenticates with the paired identity, and GTK's saved cards include trusted-but-
unpaired hosts, so an unconditional menu entry would promote a documented latent
bug — a fetch that cannot authenticate — into the default experience on every
install.

Apple and Android keep their own independent toggles for now, deliberately: both
already default TRUE, so nobody there loses anything by our not touching them, and
their removal is a follow-up rather than a rushed edit across two more UI
frameworks in this change.

VERIFICATION. The console and GTK legs are gate-green in the Linux container
(clippy `-D warnings`, a plain non-test build, and the test suites), and the GTK
leg needed that plain build: deleting the read orphaned the struct field that held
the settings store, which `--all-targets` hides and a shipping build raises.
The WinUI edits are READ-VERIFIED ONLY — `punktfunk-client-windows` is
`cfg(windows)` and compiles on neither the Mac nor the Linux container, so they
are unproven until a Windows runner sees them.
2026-08-16 19:03:53 +02:00
enricobuehler d2b86b103f fix(console): one value slip repainted every row's value, and long values escaped the row
Two reports against the settings screen, both landing in the same fifty lines of
`MenuList::render`: "the option fields all flicker when changing one option", and
"the select animation is broken in many ways, the biggest issue being text
flashing to the right of the field, completely outside — mainly with longer text".

THE FLICKER. A list has ONE slip spring and one `slip_prev` saying which row it
belongs to. The horizontal displacement correctly consults that identity; the
crossfade alpha derived from the same spring did not. So `gone` was computed from
the global spring once per row and applied to EVERY row's incoming value, blanking
the whole value column for a frame and fading it back over ~0.2 s. On a held
repeat the accumulate clamps past the crossfade's normaliser, so `gone` saturates
at 1.0 and every value sits at alpha 0 for several consecutive frames — the hard
flicker as reported. Labels were never touched, which is why it reads as the
values blinking while the labels stay put.

The existing test could not see this: it drives a ONE-row list, where the slipping
row is the only row, so an ungated alpha is indistinguishable from a gated one.
The new test uses a column of six.

THE ESCAPE. The value was placed from a single measured width shared by both the
outgoing and incoming strings, so a step between values of different lengths
teleported the outgoing text by the difference — and nothing clipped it to the
row, so a long value flew out past the right edge into open field. The field is
now fixed and each string is right-aligned by its OWN width, truncated before it
is placed rather than after, and the whole run is clipped to the row.

Three more defects in the same block, found while reading it and fixed here
because they are the same code:

- The slip armed on rows that cannot be adjusted at all. A/Confirm and a pointer
  press arm unconditionally, so pressing a non-steppable row fabricated a slide on
  a row that has no chevrons and nothing to step.
- Row identity was the index alone, which is not an identity across the per-frame
  rebuild the settings screen does — a list whose rows change shape could carry a
  slip onto whichever row inherited the index.
- A step that exactly cancels one in flight left the arming offset at zero, and
  the crossfade divides by it. That is a NaN alpha, and a NaN alpha draws nothing.
2026-08-16 19:03:30 +02:00
enricobuehler 68486b6ac8 fix(console): receded cards clamped to literal black, and the focus halo lit its neighbours
Reported from a Steam Deck: unfocused cards look "horrible — seemingly darkened,
and maybe even some sort of filter, so dark they have way too much contrast",
and "seemingly every card glows".

THE RECEDE. `recede_matrix` put its brightness term in the colour matrix's OFFSET
column, i.e. additive in sRGB-encoded space, and skia-safe's `matrix_row_major`
clamps. At RECEDE_BRIGHTNESS 0.24 that is -61/255 levels, so any channel below
0.24 clipped to zero. The coverflow's card face is a hardcoded #1E1E25, whose
post-desaturation channels are ~0.12 — every one of them lands negative. Sampling
the shipped screenshot confirms it: the interior of every side card reads exactly
(0,0,0), with a hard step from the aurora straight to black. Not a dim, a clamp.

Worse, `prox` saturates at 1.0, so cards two through five all receive the
identical treatment — the side stack was one black slab with no depth in it,
which is why it read as a filter rather than as distance.

The brightness term is now a multiplicative lerp toward the ground, so it cannot
clip at either pole and a receded poster keeps its full tonal range instead of
crushing everything below 61/255 flat. The palette-aware DIRECTION is unchanged —
receding still means "toward the field", which is down on a dark palette and up on
a pale one, and that was already right.

The veils were the other half. Both were hardcoded black while the matrix had
learned to respect the palette, so on the six pale palettes the veil spent its
alpha cancelling the lift the matrix had just applied — a grey cast over an
already-desaturated card, which is mud. They now wash toward `theme::shade`, which
is black on a dark field and white on a pale one, so all three mechanisms finally
point the same way. Their magnitudes come down as well: three stacked effects were
each tuned as though it were the only one.

THE HALO. It was never drawn for more than one card — all four call sites were
already correctly gated, and adding more gating would have been a no-op. The
"every card glows" reading came from its SHAPE: `BlurStyle::Normal` keeps the
source shape, so a 60 dp accent corona filled the focused card's own interior,
bled through the translucent panel glass, and spilled across the 58 px gap onto
both neighbours — which is precisely what "the neighbours are glowing" looks like.
`Outer` subtracts the source shape, so the halo is now only the light spilling
past the card's edge, and its reach fits inside the gap.

On a pale palette the accent halo and the black drop shadow were both simply the
wrong colour against a bright ground; the halo now mixes toward the scrim and the
shadow lightens, inside the helpers, so neither call site has to know which
palette is up.

Verified by re-deriving every predicted value: a fully receded dark card lands at
#151518 against a focused #1E1E25 — one clear step back, zero clipping — where it
used to land at #000000.
2026-08-16 19:03:09 +02:00
enricobuehlerandCursor 639886357d fix(capture/windows): a masked-color I-beam was forwarded as a fully transparent pointer
AND=1 plus a non-zero colour pixel is invert, not transparent. Treating it as a simple alpha mask dropped the text cursor, so the client installed nothing over every text field.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 22:16:05 +05:30
enricobuehler 3bfed02e02 fix(console): nothing was anti-aliased — SkPaint defaults it off, and cover art sampled nearest
Reported from a Steam Deck, where a 1280x800 panel gives sub-pixel error nowhere
to hide. Two independent causes, both API defaults rather than anything the
overhaul got wrong on purpose.

GEOMETRY. `SkPaint`'s default constructor sets `fAntiAlias = false`, and
skia-safe's `Paint::new(colour, None)` is that constructor with a colour on it.
So the terse, natural spelling of a draw call — `&Paint::new(c, None)` passed
inline as an argument — silently produces hard-stepped edges. The crate had 70
paint sites and 15 of them were anti-aliased, in an exact pattern: paints that
got MUTATED for some other reason (a stroke style, a width) picked up a
`set_anti_alias(true)` on the way past, and every inline argument did not. The
console therefore drew smooth 1 px rings on top of jagged fills, which looks
worse than no ring at all — the smooth edge gives the eye a reference for how
wrong the fill is. The padlock on a host tile had a stair-stepped body and a
clean shackle; the online pip was a blocky octagon inside a soft blurred glow.

Text was never affected — glyph anti-aliasing is `SkFont::Edging`, which
defaults to on — which is why the labels always looked right and only the shapes
looked chewed.

COVER ART. `Canvas::draw_image_rect` samples with `SamplingOptions::default()`,
which is `FilterMode::Nearest` and `MipmapMode::None` — no filtering at all.
Every poster in the library is minified hard, a 600x900 cover into a ~180x270
Deck cell, and nearest-neighbour minification simply discards rows and columns:
box-art lettering breaks up and edges crawl as the shelf scrolls. That reads as
"low resolution" whatever the panel is.

The fix is to take the choice away from the call site. `theme::fill`, `stroke`,
`shaded`, `shaded_stroke` and `layer` are now the only sanctioned constructors,
all 70 sites go through them, and `theme::art_sampling` (linear + linear
mipmaps) covers the three image draws.

`shaded` earns its place the hard way. Skia modulates a shader's output by the
PAINT'S alpha, so the obvious way to build a gradient paint — start transparent,
let the shader supply everything — draws nothing whatever. Not dimmer: absent.
`Paint::default()` happened to be opaque black, which is the only reason the
console's gradients and the SkSL aurora never met the rule; building them from a
transparent placeholder erased the backdrop, the badge, the vignette and the
skeleton sheen in one go, and all 124 tests still passed, because a test that
only renders a frame cannot tell a missing layer from a dark one. It was caught
by looking at the pixels. `shaded` is opaque by construction so the trap cannot
be set again.

Three tests, each pinning a different half:

- `geometry_is_anti_aliased` draws one circle on a half-pixel centre and counts
  partially-covered boundary pixels. Asserted on a shape rather than a screen
  because a full render is a poor witness — one jagged corner is a few dozen
  pixels in 1.02 M. Backed out, it reports exactly 0.
- `a_shaded_paint_is_opaque_enough_to_draw` covers the alpha rule above.
- `paints_are_built_by_the_theme_constructors` scans the crate's own source,
  because the aliased spelling is the NATURAL one and will be written again by
  whoever adds the next draw call. Its needles are built with `concat!` so the
  scan does not match itself, which it did on the first run.

All three were verified to fail with the fix backed out, not merely to pass.
2026-08-16 18:04:09 +02:00
enricobuehler ab3ef5b208 fix(encode): a worker that died before the Hello landed said EPIPE, not "handshake"
CI red on `a_worker_that_exits_immediately_is_a_handshake_failure`:

    the rung must name the handshake: send Hello: Broken pipe (os error 32)

The test was pinning a premise that is only usually true. `spawn_link` execs a binary
that exits at once, then races it: the parent writes Hello while the child is exiting.
Win the race and the death surfaces as the EOF the recv reads, which carries
`.context("encode worker handshake (died on startup?)")`. Lose it and the peer is
already closed, so the SEND takes EPIPE — and that arm carried only
`.context("send Hello")`, which never contains the word the test looks for.

So this is not merely a test problem. Both arms are one cause — a worker that died
during startup — and the operator was getting two diagnoses for it. On the EPIPE arm the
fallback warn in `open_preferring_worker` degraded to a bare "send Hello: Broken pipe
(os error 32)": it names neither the worker nor the stage that failed, on the one code
path whose entire job is to explain why the session just fell back to the in-process
encoder. The send now names the handshake too, and the race becomes harmless because
both outcomes satisfy the same contract.

The new deterministic rung is the part worth keeping. The spawn-driven test cannot be
trusted to catch this: it did not fail ONCE in 60 runs of the unfixed code in a
container here, because an idle box always wins the race — it is CI's load that loses
it, which is exactly why this reached main. Closing the peer before the handshake starts
reproduces the EPIPE arm with no scheduler dependence at all; backed out, it fails with
CI's exact message.

Verified with CI's own command, `cargo clippy -p pf-encode --all-targets --locked
--features nvenc,vulkan-encode,pyrowave -- -D warnings` + the matching `cargo test`, in
the amd64 container: clippy clean, 123 passed / 0 failed, and 100 consecutive passes of
each of the two rungs. `cargo fmt --all --check` clean. Non-vacuity confirmed by
`Checking pf-encode` / `Compiling pf-encode` in the log.
2026-08-16 18:01:18 +02:00
enricobuehler d92266a402 Merge pull request 'The console UI overhaul: motion, look, and a library you can actually navigate' (#269) from worktree-console-ui-overhaul into main
Reviewed-on: unom/punktfunk#269
2026-08-16 15:06:19 +00:00
enricobuehler 451f95079e fix(console): the focus recede ran backwards on all six pale palettes
Caught on the screenshot pass, which is the only place it could have been
caught: every unit test and every dark-palette render agreed the recede was
fine.

`recede_matrix` ported Apple's `.brightness(-0.24·d)` literally, and that is
dark-mode arithmetic — on a dark field, "down" is "away". This crate ships
thirteen palettes and six of them are PALE, where darkening a card increases its
contrast against a light ground. On `holo` the effect was not subtle: the
UNFOCUSED host tile rendered as the heaviest object on screen, a grey slab
beside the focused card it was supposed to be receding behind.

The recede now moves a card toward its GROUND rather than always toward black,
taking the direction from the scrim — which already encodes which way the field
leans, because it tends to black on a dark palette and white on a pale one. The
saturation drain is unchanged in both directions; colour has no handedness.

The new test asserts the two poles disagree in SIGN — a receded card sinks on
`violet` and lifts on `mint` — because that is the property, and any test
written against one palette would have passed before this fix.
2026-08-16 16:21:10 +02:00
enricobuehler d927fcb2b0 feat(console): Collections — group by platform, walk them, open one
The user's ask, verbatim: group by console, navigate the platforms, pick PS3,
see its games. Y on a library opens a carousel of collection tiles; A on one
pushes the same `LibraryScreen` with a FILTER set, titled "Host · PS3"; B walks
back out. The whole flow is asserted end to end rather than in pieces, because
it is the flow that was asked for.

It borrows the home carousel's tile language on purpose. A collection is a place
you go, exactly as a host is, and the console should have one idea of what that
looks like — same sprung cursor, same entrance, same recede, same halo. Each
tile fans up to three real covers, taken from the posters the library screen has
ALREADY decoded rather than fetched again; a group with no art yet shows its
monogram, which is also the permanent look of a platform full of art-less ROM
entries.

Filtering is index-level. The drill-in sets a `GroupKey` and the shelf collates
against it, so the shared model, the art pump and the fetch flow never learn
that collections exist — the filtered shelf is the same screen, not a copy of
the library with things removed.

The gate is a real one, and it is a pair. `worth_browsing` is false for a
library with a single group, and BOTH the Y binding and the Y hint read it, so
the legend can never advertise a press that only thuds. Launchers alone never
count toward it: every library has them, and a collections screen offering only
"Launchers" is precisely the one-tile screen the rule exists to prevent. Y is
also refused from inside a collection — a drill-in from a drill-in would collate
a set that is already one group.

Sort lives here, on the shoulders, as four pills. The shelf re-reads
`library_sort` every frame, so stepping it re-orders the collection tiles AND
the shelf waiting behind this screen at once — which is the argument for putting
it on this screen rather than in a dialog.

`LibraryScreen` stopped unpacking its host into five scalars and keeps the
`HostRow` whole, because Collections has to hand one back to `LibraryScreen::new`
and two partial copies of the same host is the state that goes stale first.

One thing worth recording: the flow test failed on its first run, asserting the
filtered shelf held 0 titles instead of 2 — a screen adopts the shared model on
its first sync, and the test asked before the app would have rendered. The
sequencing was the bug, not the filter, and the test now draws a frame the way
the console does. It is the kind of thing that would otherwise have been "fixed"
by weakening the assertion.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 121 console-ui + 213 client-core tests green.
2026-08-16 16:04:14 +02:00
enricobuehler c354d7cd09 feat(console): a grid arrangement for the library, plus art eviction and a skeleton shelf
The coverflow is a browsing surface: one cover at a time, big, artwork doing the
talking. It is the wrong shape for "I know what I want, show me everything",
which on a 200-title shelf means holding Right and watching. The grid is that
other surface — about eighteen covers at once — and it is a VIEW rather than a
second screen: same cursor, same collated order, same art cache, same detail
band underneath. Only the arrangement differs.

`grid_step` is the pure half, and it carries the one real subtlety: the two
boundary rules are different on purpose. A row's ENDS refuse, exactly as the
shelf's do, because wrapping would make a held Right scan the whole library and
the shoulders already do that. But Down into a SHORT last row CLAMPS to the last
title rather than refusing — a ragged tail is a layout accident, not a boundary
the user chose to hit. Both, plus page jumps and degenerate grids, are pinned by
tests.

Where the toggle lives is a deviation from the plan, and deliberate. The plan
put View on X and Collections on Y, but X is already Copy link in the library
and the grid needs Up/Down for rows, which leaves exactly one free face button
for two features. Rather than evict a shipped binding, `library_view` becomes a
settings row in Interface beside the palette and reduce-motion — which is what
that tab is for, and this is a preference you set once, not something you toggle
mid-browse. Y stays free for Collections.

Art eviction is not cosmetic. `LibraryScreen::art` had no eviction at all, so
every cover ever scrolled past stayed decoded for the life of the screen — the
shelf touched a dozen and hid it; the grid touches all of them. Posters are now
frame-stamped as they are DRAWN and the coldest trimmed past a budget of 160.
Stamping after the draw rather than on arrival is the load-bearing detail, and
the test says so: an LRU keyed on arrival would cheerfully evict the six covers
the cursor is sitting among. Only decodes are trimmed — the encoded bytes are
not kept here either way, so the fetch pipeline is untouched and a re-entry
costs one frame of grey.

Loading becomes a skeleton in the arrangement the real cards will arrive in,
with one travelling sheen — the wait is for a LIST whose shape is known before
its contents are, so showing that shape is more honest than a blank field that
abruptly becomes a coverflow. It costs one extra paint and freezes under reduced
motion.

Two things fell out of the refactor and are worth naming: the art-less card face
is now one function both arrangements call (a launcher without a poster still
must not read as a game whose cover failed to load), and the detail band is
shared rather than copied — it also gained the platform, which is the whole
point of plumbing it, because "Shadow of the Colossus" means something rather
different with "PS2" under it.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 118 console-ui + 213 client-core tests green. One run aborted
inside rav1d's CDEF filter; that is environmental (emulated amd64), confirmed by
A/B — the immediately preceding run carried the identical pf-client-core sources
and passed 213/213, and the re-run passes.
2026-08-16 15:55:32 +02:00
enricobuehler c804a0dc9f feat(console): plumb platform to the shelf and give it a collation module
`GameEntry.platform` ("PC", "PS2", …) has crossed the wire since the model
landed and was thrown away at the console's boundary: both `LibraryGame` mapping
sites in the session binary built the struct without it. It is what the
rom-manager plugin populates, and without it "group by console" has nothing to
group by. Two lines, and the fake-library loader inherits it for free because it
maps the same entry type.

The interesting half is `collate` — a pure module, no Skia, because grouping
rules are exactly the kind of thing that reads obviously correct and is quietly
wrong, and because it is the half that has to be identical on every client. This
file is the portable spec the Apple and Android ports implement.

Two rules earn their tests:

- A platform-less game does NOT go to "Unknown". A Steam library has no
  platforms at all, so an "Unknown" bucket would swallow it whole and be a worse
  view than no grouping; a store-front game buckets under its STORE ("Steam"),
  which is both true and useful, and only an entry with neither lands in
  "Other".
- A-Z folds the leading article. "The Witcher 3" belongs under W; left alone
  every "The …" piles up under T and the sort is useless exactly where a long
  library needs it. English articles only, with a guard so a title that IS an
  article doesn't sort as an empty string and float to the front.

Launchers lead by construction rather than by every caller remembering to put
them there, so design D4 survives grouping for free. Everything returns INDICES:
the art cache, the fetch pump and the cursor all key off the shared model's
ordering, and handing back cloned games would fork the identity of every title.

The shelf now routes its display order through `collate::filtered` even
ungrouped and unsorted — which is why this lands green on its own, and more
importantly is the right shape: shelf, sort and the coming drill-in are then one
screen with one cursor arithmetic. `SortKey::HostOrder` is the default and is
asserted byte-identical to the order the host sent, so a user who never touches
a sort pill sees no change whatever. `LibraryScreen` gains a `view: Vec<usize>`
the cursor indexes instead of `games`, and a `filter` the drill-in will set.

`library_sort` joins `trust::Settings` beside `ui_palette` and `reduce_motion` —
presentation only, never in a profile, parsed leniently so a newer client's key
reads as today's shelf. Its pills arrive with the Collections screen; the sort
is honoured now.

`clients/session/fixtures/mixed-platform-library.json` is the standing dev asset
the rest of Part C is built against: launchers, five platforms, several stores,
platform-less entries, and two deliberately awkward titles ("The Witcher 3",
"Émigré") so a broken fold shows up on glass and not only in a unit test.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 111 tests green.
2026-08-16 15:44:17 +02:00
enricobuehler be8183caab feat(console): host tiles get their OS mark, the chip gets a battery, the strip gets Rescan
`HostRow.os` has been plumbed since the model landed, with a comment saying the
drawing was a follow-up because "the Skia glyph set doesn't exist yet". It does
exist: assets/os-icons ships thirteen licensed masters, and
`pf_client_core::os::os_icon_tokens` already resolves a chain to them - walking
most-specific-first and applying the brand aliases (`macos` -> `apple`,
`steamos` -> `steam`). Every other front-end walks that same list.

So the console takes the shared resolver rather than inventing one, and gets its
table GENERATED from the masters (`scripts/gen_os_mark_table.py`, hooked into
the existing `gen-os-icons.sh`) rather than hand-transcribed. Thirteen paths of
up to 3.5 kB where one mangled character is a silently wrong logo is not work
for a human, which is precisely the reasoning the launcher-icon tables already
carry. A new master now reaches the console for free; the script's closing note
says so.

Two corrections to the plan this implements, both found in the code:
- The chain is SLASH-separated and resolves most-specific-FIRST, not "the first
  known token of a `;`-chain". A `linux/fedora/bazzite` host draws Bazzite, and
  falls back through Fedora to Tux - so the console is right about thirteen
  distros rather than the four the plan scoped.
- The hint bar was already a glass pill, not "ink on the field". What it was
  missing is that it mixed its OWN glass (a flat wash and a hand-rolled stroke),
  making it the one floating surface that ignored the palette; it now goes
  through `theme::panel` like the chip and the toast, and picks up the lit edge.

`draw_monogram` becomes `draw_badge`: the OS mark when the chain resolves, the
initial when it doesn't. A substitution, not an addition - a badge showing both
a Tux and an "L" says the same thing twice - and an older host that advertises
no `os` keeps its monogram pixel for pixel.

The controller chip gains a pad silhouette and a battery pip. `PadInfo` gets an
additive `battery: Option<PadBattery>`; nothing crosses the wire, this is local
SDL state. The plan expected to poll "on the existing pad-refresh cadence" -
there isn't one, `publish()` is entirely event-driven (hotplug, pin change). And
`pad_info` is deliberately open-free because an open GRABS the hardware, while
SDL only reports power for an OPEN device. So the level is read from the ONE pad
the service already holds open - `menu_open`, the nav pad, which is open exactly
while a console is on screen and is the only pad any UI asks about - on a 15 s
poll inside the loop that already wakes every 10 ms. Every other pad publishes
`None`, which is the honest answer.

`None` renders as no battery at all, never 0 %: a wired pad, a Steam virtual pad
and SDL's `-1` "powered, level unknown" are all the same non-answer, and 0 % is
the one reading that sends someone hunting for a charger. Charging outranks the
low-charge red, because a pad at 4 % on the cable is not the problem a pad at
4 % off it is.

Finally, Rescan: a second sentinel tile trailing Add Host, sending the
`ConsoleCmd::Probe` that has existed unsent by any screen since it was written.
A controller surface has no pull-to-refresh, so the affordance has to be a tile.
The two trailing tiles are actions rather than hosts, so `hosts.get(i)`
answering both "which host" and "which action" with one `None` became a `Slot`
enum - with a second action tile that ambiguity is a bug waiting, and the test
that matters is that an accidental A on the end of the strip can never start a
session.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 104 tests green.
2026-08-16 15:36:36 +02:00
enricobuehler a289f7b9af feat(console): recede by draining colour, not by painting black over it
An unfocused card lost scale and gained a flat black veil. A veil only darkens,
so a receded card stayed exactly as colourful as the focused one and the eye
went on reading it as a competing subject - which is why the veil had to be
heavy to work at all, and why the shelf read as "dimmed" rather than "deep".

`theme::recede_matrix` adds the two channels the Apple gamepad UI uses and this
crate did not: saturation down 42 % and brightness down 24 % at full recede, as
one 4x5 colour matrix on the layer each card composites through. The veil then
drops to HALF its old strength on both surfaces (home 0.24 -> 0.12, the
coverflow's RECEDE_DIM 0.30 -> 0.15), left doing the one job a flat darkening is
good at: separating cards that overlap.

Alpha is deliberately untouched by the matrix, and there is a test saying so.
The coverflow's side cards overlap, so a recede that reached alpha would let
them show through each other - which is the reason the veil exists in the first
place. A second test pins the focused card as EXACTLY identity, because a matrix
that tinted the focus by half a percent would be invisible in review and wrong
in every screenshot.

The focused tile also gains an accent-tinted halo under its shadow - the
palette-aware mark that says "this one" from a sofa, where a 12 % scale
difference says nothing. On the coverflow it is drawn in screen space before the
card's own transform, because it is light spilling AROUND the card and cannot
live inside the rounded rect the card clips itself to; it fades with the sprung
proximity rather than snapping on the integer cursor, so it travels with the
strip.

Glass gains a lit top edge (`theme::panel_highlight`): a 1 px inner stroke
fading out over the top 40 %, which is what makes a material read as material
rather than as a tinted rectangle. Deliberately a separate call rather than a
flag on `panel`, so the cost discipline is visible - tiles and the one focused
row get it, the dozens of resting rows a settings screen paints every frame do
not.

Cost, stated plainly: the coverflow previously had no per-card layer, so the
recede adds one per VISIBLE card there (bounded to the card by raising it after
the clip, and skipped entirely for the focused, settled one). That is the plan's
named O(visible cards) price and the thing to watch on a Deck frame graph. The
home carousel already composited each tile through a layer, so it pays nothing
extra.

The typography sweep found less than expected, which is worth recording: the
crate is already almost entirely on the 0.55 / 0.7 / 0.85 ladder. Two genuine
outliers, both in the library - a group heading a shade louder than the
identical role in `MenuList` (0.5 -> 0.45) and a detail subtitle off the ladder
(0.5 -> 0.55). The `fg(0.92)` runs are NOT outliers: they are letters on a
button badge and text on a toast pill, sitting on their own ground rather than
on the field. `ROW_MAX_W` is already Apple's 620, and the 30 dp title against 16
dp row labels is within a hair of Apple's 34/17 rhythm, so neither moved.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 99 tests green.
2026-08-16 15:25:13 +02:00
enricobuehler 50ef253e43 feat(console): the screen transition becomes an interruptible spring (vectors v2)
Pressing B while a screen was still arriving did nothing at all. The transition
was a 0.26 s ease-out-cubic and every event that landed inside it was thrown
away, so holding B to back out of a deep stack stuttered at every level: press,
wait, press again. That wall is the one place the console felt less alive than
the Apple shell it is modelled on.

`Motion` becomes one sprung scalar. The paint recipes read `spring.pos` exactly
where they read `ease_out_cubic(raw)` - same 36 dp slide, same 0.985/0.96
scales, same 0.4 reveal alpha, so the choreography is untouched and only the
time-course changed. Those four numbers stop being literals buried in the paint
sites and become named constants, because the vectors file claims to pin them
for all three clients and a literal is a thing no test can reach.

What the spring buys is the retarget. Back mid-push flips THAT SAME spring from
1.0 to 0.0: velocity carries, so the entering screen decelerates, turns, and
goes back down as one continuous motion, and `finish_nav` takes it off the stack
when the spring lands. A tween cannot do that - its progress is a function of
elapsed time, so reversing means either a snap or a second animation played
behind the first. The test asserts the continuity directly (no sample jumps by
more than a frame of the travel already underway), because "cancel and play a
pop" would pass a weaker test and still look wrong.

Back is now always heard; everything else stays queued-dropped until the spring
passes 0.85, so the double-tap protection survives. The threshold is on
POSITION, not elapsed time - with a spring those are different questions, and
the one that matters for input is whether the screen under the cursor is the one
being aimed at. Mid-pop, A is still refused (activating a half-dismissed screen
is a mis-tap, not intent) while Back starts the next pop immediately, which is
the stutter this removes. At the root the transition declines the press, because
there is no parent to fall back to and B there means quit.

Parity: `console-vectors.json` gains a `motion_spring` block and goes to version
2. Springs are integrator-dependent, so v2 pins PARAMETERS where v1 pinned
sampled positions - two runtimes that both honour response/damping agree to the
eye and disagree in the third decimal, and sampling would pin the disagreement
instead of the feel. The v1 `motion` block stays in place, marked `$deprecated`
and still correct, because the Android client's ConsoleVectorsTest reads it;
this crate's test switches to the new block and asserts the note is still there,
so deleting v1 here would fail loudly rather than redden Android silently.
(Note for the follow-ups: the Apple client mirrors the v1 constants in
GamepadShell but does NOT test them - its ConsoleVectorsTests covers the palette
table only. The plan assumed otherwise.)

`Progress` is deleted. The transition was its last caller, and this crate lints
dead code as an error - keeping a timer nothing times behind an allow would be
the wrong kind of tidy.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 97 tests green.
2026-08-16 15:19:25 +02:00
enricobuehler 2314637c0d feat(console): screens arrive instead of appearing - the staggered card entrance
Until now a console screen popped into existence fully formed; the only thing
that moved was the shell's own push slide. This ports the Apple gamepad UI's
`CardEntrance` semantics as `anim::Entrance`: a pure function of the shell clock
and an item's distance from an anchor, so a screen holds ONE of them and asks it
per item. No per-card state to keep in step with a host list that churns under
discovery.

Per item: a delay of 0.07 s per step from the anchor (capped at 0.42 s, which is
what stops a 400-title shelf still arriving a minute later), then a 0.6 s window
of ease-out-BACK travel - it crosses 1.0 and settles back, which is the
difference between a card thrown into place and one slid there - with the fade
finished in the first third, so what you read is the motion and not a dissolve.
The anchor is the CURSOR, so a restored selection assembles around the eye.

Three surfaces, each applying the envelope to its own geometry:

- The home carousel: tiles arrive 26 % small, 34 dp low, fading, folded into the
  transform and layer alpha each tile already applies. Hit boxes follow the
  drawn geometry, because a press during that second must land on the card the
  eye sees.
- The library coverflow: the same, plus a real Y-turn on the card's OWN rotation
  matrix. Apple had to fake its turn with a cos-squeeze because SwiftUI cannot
  snapshot a rotated layer to glass; Skia has no such constraint, so the strip
  genuinely fans open like a book. Cards turn away in the direction they sit
  from the anchor.
- Menu lists: 12 dp rise, 0.03 s stagger, capped at eight rows. Same language,
  deliberately quieter - a settings list that fans open like a shelf of box art
  is a settings list showing off. NOT replayed on a tab switch: re-fanning the
  rows on every L1/R1 would turn a skim through the sections into a flicker.

The library waits for content before it plays. Art streams in per title AFTER
the list lands, so without a gate the entrance would reliably show off a rank of
grey placeholder faces; it arms once the cards around the cursor have posters,
or 400 ms have gone by and they clearly are not coming - so an art-less library
of custom entries still gets its entrance, just later.

On cost: the coverflow's resting frame is byte-for-byte as cheap as it was. It
deliberately has no per-card layer - side cards overlap, so it dims them with an
opaque veil rather than whole-card alpha - and the entrance raises one only
while a card is still translucent, after the clip so `None` bounds mean the CARD
and not the screen. A full-screen layer per arriving card is the one way this
could have cost real time on a Deck.

Under reduce motion the whole thing degrades to a staggerless crossfade, decided
once when the entrance is armed so one entrance plays one way.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 94 tests green.
2026-08-16 15:11:55 +02:00
enricobuehler 46d648cf0f feat(console): name the motion, spring the micro-motion, and let it all be turned off
The console's feel was scattered across a dozen hand-picked time constants, so
"how a focused row arrives" was a tau someone chose in a hurry rather than a
decision anyone could find. `anim.rs` grows a `SpringSpec` table (FOCUS,
INDICATOR, PRESS) with a const `kc()` that performs SwiftUI's
response/damping -> k/c conversion; a test pins it against TRAY_K/TRAY_C, which
were hand-derived from exactly that arithmetic, and a second states the damping
choices as behaviour (FOCUS must overshoot, INDICATOR must not).

Three widgets stop easing and start springing, each for a reason:

- Row focus splits into two channels. Scale is sprung, because the whisker of
  overshoot is the pop that makes a row read as picked up; tint and alpha keep
  `approach`, because an overshooting colour lands on a tint that is not in the
  palette.
- The tab pill's x and width are sprung, so a fast skim through the sections
  accumulates into one accelerating travel instead of a series of eases that
  each restart at zero speed.
- A stepped value slips 14 dp and springs back, crossfading the old value out.
  The list detects the change ITSELF by comparing what it drew against what the
  screen hands back, so no screen has to report whether its edit landed - and a
  refused adjust correctly produces no motion at all.

Toasts gain a kind. "Paired with X", "Session ended" and "Couldn't connect" were
the same grey pill, so the only way to tell them apart was to read them; each
now carries a mark and a hairline. Error is the one tint that is fixed rather
than palette-derived: moss's accent is a green, and reporting a failure in the
colour the UI uses for "this is fine" is the bug.

And a Reduce motion setting, under Interface beside the palette. It freezes the
backdrop shader's clock (gated in `draw_aurora`, so the connect takeover
inherits it), flattens push/pop to a 0.2 s crossfade with no slide or scale, and
drops the recoil and slip travel - while keeping every haptic, because the pulse
is what replaces the motion, not another thing to take away. A console setting
rather than an OS mirror: there is no system reduce-motion SDL can read
portably. It doubles as the OLED-friendly mode.

Verified in the pf-gtkflow container (this crate is invisible to a Mac checkout -
`cargo check -p pf-console-ui` there compiles nothing at all): fmt, clippy
--all-targets -D warnings, a plain build, and 90 tests green.
2026-08-16 15:03:00 +02:00
enricobuehler adf895ced1 Merge pull request 'main is red: the PyroWave worker proxy never wrote distrust_references' (#268) from worktree-pyrowave-distrust-refs into main
Reviewed-on: unom/punktfunk#268
2026-08-16 11:56:30 +00:00
enricobuehler 18fdc4cc6b fix(encode): the PyroWave worker proxy never wrote distrust_references, so its own guard test failed
Pre-existing on main, inherited by merging it. `distrust_references` arrived with the RFI
anchor work in `93c1ed07`; `RemotePyroWave` did not gain it, and the trait-coverage test
beside the impl caught exactly that.

The answer is a no-op, for the same reason `invalidate_ref_frames` returns false and
`request_keyframe` does nothing: PyroWave is intra-only, so every AU is already a
keyframe and there is no RFI anchor trust to withdraw. The in-process encoder reaches
the same answer by inheriting the trait default, and that is fine there.

It is not fine HERE, which is the point the guard test is making. This type is a proxy:
an inherited default means the worker never hears the call, so a method that does
something is silently dead on every worker-backed session while the in-process path
keeps working. The test therefore requires the method to be WRITTEN, so that "nothing to
do" is a visible decision rather than an omission. Written, with the reason.

Verified with CI's exact command — `cargo test -p pf-encode --features
nvenc,vulkan-encode,pyrowave` — 122 passed, and clippy clean under the same features.
2026-08-16 13:39:15 +02:00
enricobuehler 7ba344cf4e Merge pull request 'Lossless audio: the 0xD3 PCM plane, 44.1–176.4 kHz, surround, verified on glass (ABI 24)' (#263) from worktree-hires-audio-pcm into main
Reviewed-on: unom/punktfunk#263
2026-08-16 11:18:28 +00:00
enricobuehler e6476eddcf fix(core): a doc line starting with > read as a markdown blockquote and failed clippy
`--all-targets` clippy treats `/// >24 kHz` as the start of a quote block and then
rejects the unmarked lines that continue it. I added that test and gated it on tests and
fmt but not clippy, so it would have reached CI red. Reworded rather than escaped —
the sentence reads better without the symbol anyway.
2026-08-16 12:55:20 +02:00
enricobuehler 47c9162433 Merge remote-tracking branch 'origin/main' into worktree-hires-audio-pcm
# Conflicts:
#	crates/pf-client-core/src/pad_audio.rs
2026-08-16 12:50:15 +02:00
enricobuehler a9ffd3becd feat(host): a Hello that names a format but not the capability was ignored in total silence
Found on glass, and it cost the first run of the §13.2 check. The host resolved
`plane="0xC9 Opus"` while every condition a reader could see was satisfiable — the
operator policy was on, the session was stereo, the rate was supported, and the video
bitrate left room. The reason was gate condition 1, which is deliberately NOT logged
because "the client did not set the capability" is every ordinary session with every
shipping client and would drown the log.

That reasoning holds for the ordinary case and not for this one. A `Hello` carrying a
rate or a depth but no `CLIENT_CAP_AUDIO_HIRES` is contradictory: something asked, and
is being ignored. The two halves come from different places in a client — the capability
from a settings toggle, the format from whatever that toggle resolved to — so they can
drift apart, and an embedder that gets it wrong currently sees nothing at all.

One warn, only in the contradictory case, naming both halves and saying they must be set
together.
2026-08-16 12:39:42 +02:00
enricobuehler 362be21ebb fix(probe): the format was on the wire but the capability bit was not, so the host was right to ignore it
Correcting my own previous commit, which claimed the probe had been "asking for the
lossless plane by accident". It had not, and the comment I deleted for being wrong was
right.

This `Hello` is built BY HAND and never passes through `advertised_client_caps`, the
helper that derives CLIENT_CAP_AUDIO_HIRES from the requested format for the shipping
clients. So the probe's explicit 48000/16 was inert: the host's gate tests the
capability first, and without the bit a format on the wire is a request it correctly
ignores. No session was ever mislabelled, and the old comment's "never sets
CLIENT_CAP_AUDIO_HIRES" was an accurate statement about this file.

What was actually broken is what I added: `--audio-format` set the rate and depth and
not the bit, so it asked for nothing. Found on glass — the host resolved
`plane="0xC9 Opus"` while every gate condition looked satisfiable, and nothing was
logged, because condition 1 is deliberately unlogged (it is every session with every
shipping client). Both are set together now, and the comment says why they must be.

With that fixed the plane resolves end to end against a real Linux host: 0xD3, 96 kHz,
24-bit, 192 samples per channel — the 2 ms frame the ladder predicts for 96/24 at the
default MTU.
2026-08-16 12:37:47 +02:00
enricobuehler bca85db160 fix(probe): it asked for the lossless plane by accident and then discarded every frame of it
The probe sent an explicit 48 000/16 as its "legacy" audio request. Under the capability
rule that is a genuine hi-res request — the bit is set when either field is non-zero,
because it keys on "the caller specified a format" — so against a host with the operator
policy on, the probe advertised the capability, was handed the `0xD3` plane, and counted
nothing, because its decode arm only ever matched `0xC9`.

Its own comment asserted the opposite ("never sets CLIENT_CAP_AUDIO_HIRES"), which is
what made this invisible to a reader. Same shape as the Android bug, same root: a doc
that stopped being true when the rule changed. It sends the `0`/`0` sentinel now.

Then it learns the plane it was accidentally asking for. `--audio-format` takes
`opus` (the default, unspecified) or a rung, validated against core's own supported-rate
predicate rather than a restated list; `--audio-out FILE` writes the DECODED audio as
raw interleaved f32 for offline analysis.

That last one is the point. Design §13.2 — "play a >24 kHz tone on the host and confirm
it arrives" — has been the one check no test suite can replace, and it was written as a
listening session. It is now a command: play the tone, run the probe with
`--audio-format lossless96 --audio-out`, and look for energy above 24 kHz. A brick wall
there indicts the host's capture specifically, because core's own test already proves
the transport carries it.

The decode arm reads the RESOLVED format off the Welcome, never the request. The host
may decline hi-res for any of the five reasons in the gate and answer Opus, and a probe
that trusted its own ask would mis-parse every datagram it was actually sent.
2026-08-16 12:19:51 +02:00
enricobuehler b347682eb2 feat(console-ui): Gaming Mode was the one client surface with no audio-format row
Every other client got the picker; this one load-modify-saves the whole settings struct,
so a format chosen elsewhere round-tripped through it untouched and simply could not be
set from the console.

The row renders from `pf_client_core::session::AUDIO_FORMATS` rather than restating the
values, so it picks up rungs the other clients add without being edited. An unrecognised
stored value — a newer client's rung arriving through the shared profile catalog —
renders as Opus rather than a dash, because that is what the wire mapping resolves it
to; the row and the request agree.

The stereo gate stays, but for a different reason than the one that was written down.
The host's `channels != 2` decline is gone and the arithmetic supports that: at 48 kHz
both 5.1 and 7.1 fit a 1 ms frame. But `pf-client-core` still filters a surround request
out before it reaches the wire, so a live row under 5.1 would be a control that changes
nothing — precisely the lie this screen dims rows to avoid, and it would disagree with
the GTK dialog reading the same settings file on the same machine. The comment says the
old reason is dead, names the real one, and says to delete the arm when the client-side
filter learns the ladder rather than when the host rule changed.

No override marker or profile commit: this screen has neither for any row, by design —
the console pins profiles rather than editing them.
2026-08-16 12:19:49 +02:00
enricobuehler f206feb588 test(core): nothing proved that the plane actually carries what it exists to carry
The whole reason for a second audio plane is that Opus is 48 kHz by construction, so
content above 24 kHz is gone before its encoder sees it. That claim had no test. Every
existing one checks bit-exactness code by code, which says nothing about frequency —
a pipeline that silently band-limited would pass all of them.

A 30 kHz tone now goes through the real `0xD3` payload path at 96 kHz and 176.4 kHz and
must come out with its energy intact and every sample inside one 24-bit code.

The detector's discrimination is asserted too, in the same test. A single-bin DFT that
reads high everywhere would "prove" survival through a pipeline that deleted the tone,
so the test also measures a frequency that is NOT in the signal and requires it to read
as silence. A spectral assertion without that guard is decoration.

This is the SOFTWARE half of design §13.2. The other half — that the host's capture did
not resample on the way in — is WASAPI autoconvert and PipeWire's resampler, and needs a
host and an interface. What this settles is the inference: a brick wall at 24 kHz in an
on-glass spectrum now indicts the capture path specifically, because the transport is
known to carry it.

Goertzel rather than an FFT, so this costs no dependency in a crate that ships into
five clients.
2026-08-16 12:09:47 +02:00
enricobuehler cd8e19a7d5 Merge pull request 'A pipewire type in a cfg(any(linux, test)) signature has been holding the Windows client leg red' (#267) from worktree-padaudio-win-cfg into main
Reviewed-on: unom/punktfunk#267
2026-08-16 10:02:02 +00:00
enricobuehler 47d8301aeb feat(host): the presentation clock advanced by a frame's label rather than its length
Admits the 44.1 kHz family and hi-res surround, and fixes the drift that admitting them
turns from latent into live.

`next_pts_ns += frame_us * 1000` advanced the clock by the frame's NOMINAL duration.
That was harmless while every rate divided every rung, and 44 100 Hz divides none of
them: a "5 ms" frame carries 220 samples per channel, which is 4 988 662 ns. The clock
therefore invented 2 267 574 ns every second — 2 272 ppm, 8.2 seconds an hour — and the
re-anchor beside it is `max`, so it only ever corrected FORWARD. A fast clock was never
pulled back, and the A/V sync loop would have chased it forever.

The clock is now a running sample total rather than a sum of per-frame durations, which
accumulates exactly zero where summing floored frames accumulates about a nanosecond a
frame. It lives in a small `PtsClock` because the drift sat inside `audio_thread`, which
no test can reach; as a struct it is provable, and the planted-error run pins every
ladder rung rather than just 5 ms. `advance` folds whole seconds out of the sample count
into the base — exact, since `rate x channels` samples is precisely one second — because
`usize` is 32-bit on some targets and 176.4 kHz 7.1 would wrap it in under an hour.

The frame pacer was changed to agree even though it is cosmetic: it is gated on real data
being available, so a nominal value produced a slot the pacer waited out rather than time
it invented. Two clocks describing the same frame and disagreeing by 0.23 % is the kind
of thing a later reader reconciles in the wrong direction.

The rate gate reads `pcm::rate_is_supported` instead of restating the set. The Windows
48 kHz floor is untouched and never needed touching — the `max` sits inside the
"requested above engine" arm, so a 44 100 request never tripped it. The comment now
separates the two rules that read as one line: refusing to ask for more than the engine
has is the honesty rule for either plane; the 48 kHz floor is for Opus alone, because
libopus accepts 8/12/16/24/48 kHz only.

Surround's `channels != 2` decline is DELETED rather than relaxed. The design called it a
one-line restriction; it was worse than that, because the check ran BEFORE the frame
ladder and overrode the one piece of code that knows the answer. The ladder is
channel-aware, so it decides and `None` declines — and the design's blanket "surround is
out at the default MTU" turns out to be wrong in both directions: 48 kHz 5.1 fits at
1.5 ms and 7.1 at 1 ms, while 16-bit 5.1 fits even at 88.2 and 96 kHz. What is actually
true is that nothing surround fits above 48 kHz in 24-bit, and no 7.1 fits above 48 kHz
at all.

Two stale figures corrected while adjacent: the budget doc claimed a 20 Mbps session
affords the whole stereo ladder (176.4/24 stereo is 8.5 Mbps and wants 33.9), and the
operator-facing decline log quoted 1.5–4.6 Mbps for a plane that now spans 1.4–8.5 in
stereo and 33.9 in 7.1.
2026-08-16 11:52:53 +02:00
enricobuehler 6bbdafc289 feat(apple): the Swift port of the de-jitter policy had the same divide-first defect as core
This client hand-ports `JitterPolicy`, `AvSync` and `DroughtConceal` into Swift rather
than draining core's decisions, so every fix core just made had a second, silent copy
here.

`perMS = (rateHz / 1000) * channels` divided before it multiplied, exactly as core did,
so 44 100 Hz became 44 samples per millisecond and every depth, target and reported
figure was 2.3 % low. All sixteen conversion sites now multiply first. `frameSamples`
delegates to the same floor-per-channel rule core uses instead of deriving from `perMS` —
at 44 100 Hz a 5 ms FRAME is 440 interleaved samples where 5 ms of AUDIO is 441, and the
shed and the near-miss margin both mean "exactly one packet", so a self-derived answer
described a packet that does not exist.

Swift traps on overflow rather than wrapping, and the samples-to-ms direction takes a
caller-supplied count that `setSyncTarget` can drive to `Int.max / 2`, so that one
saturates explicitly — an untrapped multiply there would abort inside the render
callback.

`DroughtConceal` counts frames now and derives both millisecond figures from the
resolved frame, and its two thresholds are two real frames rather than a fixed 10 ms.
The old shape charged 5 ms per concealed frame whatever the frame was: on the 1 ms frame
that 48 kHz/24-bit 7.1 lands on, it bought a fifth of the concealment it promised and
reported 24 ms of synthesis as 120. The frame count stayed right, which is why nothing
played wrong and nothing caught it — only the relationship between the count and the
frame length exposes it, and that is what the new test asserts.

The stereo gate is gone from all three settings surfaces and from the connect path, and
the format picker offers the 44.1 family. The resolved rate is rendered with its
fractional kHz — integer division would have printed a 44 100 Hz session as "44 kHz",
and `String(format:)` would have localised the separator.
2026-08-16 11:33:02 +02:00
enricobuehler 0196c75769 fix(android): every Opus session was advertising the lossless capability, and the ABI doc told it to
Two things: a live opt-in bug, and the 44.1 kHz family.

Android returned `(48_000, BITS_16)` for the "Standard (Opus)" setting and handed it to
`connect_with_audio_format`, whose capability rule sets the bit when EITHER field is
non-zero — it keys on "the caller specified a format", not "the format differs from the
default", because otherwise 48/16 would be the one rung on the ladder nobody could ask
for. The host's gate accepts 48/16 as a supported format. So on any host with the
operator policy enabled, a user who chose Opus silently got the lossless plane at
1.5 Mbps.

`punktfunk_connect_ex11`'s doc caused it: it said passing 48000/16 was "exactly `ex10`,
byte-for-byte on the wire". That stopped being true when the capability rule changed and
nobody updated the sentence. It now says the opposite explicitly, including that a
hardcoded 48 000/16 as a stand-in for "default" opts every ordinary session in. Both ends
of the Android path send the `(0, 0)` unspecified sentinel now, and two tests pin the
asymmetry from either side of the JNI boundary.

The 44.1 kHz half mirrors core: the client's own `per_ms` had the same divide-first
defect, so its conversions are exact now, and `frame_samples` delegates to core rather
than re-deriving — at 44 100 Hz a 5 ms FRAME is 440 interleaved samples where 5 ms of
AUDIO is 441, and the shed and near-miss margin both mean "exactly one packet".

The openable-rate probe descends the requested rate's own family before falling to the
48 kHz floor, because AAudio grants a rate or fails and never substitutes. The floor is
accepted unprobed — universally granted, and the answer a failed probe falls back to —
so a default session still opens no stream and pays nothing. The probe uses the requested
channel count because the resolved one does not exist until the Welcome, which errs
toward Opus.

Surround is no longer hidden. The gates were removed rather than relaxed: core's ladder
is channel-aware and the host decides, so the client asks and reports what it was given.
2026-08-16 11:27:48 +02:00
enricobuehler f30f7e88b0 feat(client): the desktop had no way to ask for the lossless plane except an environment variable
Apple and Android both shipped a real audio-format picker; the desktop had a stopgap env
lever, because `SessionParams` had no field for it and the one struct literal that fills
it lives in another crate. It has one now, and the four clients share a vocabulary.

The stored values are byte-identical to Android's and Apple's — `opus`, `lossless48`,
`lossless96` under the key `audio_format` — because a profile that round-trips between a
phone and a TV but not to the desktop is the exact bug worth avoiding. They are read from
those clients rather than invented here, and a value this build does not recognise
resolves to Opus rather than refusing the connect.

`PUNKTFUNK_AUDIO_HIRES` still overrides the setting, in BOTH directions, which is how
this crate already treats every other `PUNKTFUNK_*` lever. A lever that loses to a stale
profile is useless for the thing operators reach for it for, and one that can only switch
a feature ON is half a lever.

One behaviour deliberately changed: an unparseable value used to mean "off". It now warns
and is ignored, so the user's setting still decides — garbage silently defeating a switch
somebody set in a UI is worse than the pre-UI behaviour it replaced. Unset, off and
garbage are three distinct outcomes now, and a test pins all three.

The precedence itself is a pure function of (env, setting) so it can be tested without
mutating the process environment, which is the idiom the neighbouring parse already used.

Stats carry the RESOLVED format off the Welcome, never the requested one, and the OSD
prints it — a desktop that says "lossless" while the host declined is the same class of
lie as claiming a sample rate you did not get.
2026-08-16 11:27:32 +02:00
enricobuehler ec500b0943 feat(host): monitor mode declined hi-res because nobody had written the lookup it was waiting on
The design said hi-res is offered in stream-sink mode and declined in monitor mode
"unless the monitored node's own rate can be read from the registry", and that lookup
was never written — so `PUNKTFUNK_STREAM_SINK=0` could never have the feature at all,
by omission rather than by decision. It exists now.

Reading our own capture stream's rate would be worthless: in monitor mode we tap someone
else's sink THROUGH PipeWire's resampler, which reports a clean rate whatever is
upstream — the same blindness as WASAPI's autoconvert, which is the trap this whole
feature is built around. So the lookup binds the graph's elected default sink and reads
that NODE's format: one bounded round-trip with a 3 s timer, so a sick graph costs a
fallback rather than a stalled handshake.

It reads `default.audio.sink`, the elected default, NOT the neighbouring
`default.configured.audio.sink` that this host's own stream-sink code writes. That one
is a preference: unset on a box nobody configured, and able to name a node that no
longer exists.

Unknown declines. No graph default, no format, no answer inside the timer — each
resolves to Opus rather than guessing, because the whole point is that a session must
never claim a rate its content does not have. That asymmetry is stated at the lookup, at
the call site and in the module doc, since "unknown means fine" is the opposite
convention to the one the wiring plan uses a few files away.

One nuance the design does not address: a monitor tap emits the GRAPH-side rate, while
`Format` on an adapter node forwards to the device side. On an ordinary box those are
the same number, because PipeWire opens the device at the graph rate whenever it can.
They diverge only for a device that cannot run the graph's rate, where reading the
device side declines something that would have worked — the safe direction. The exactly
right source is the sink's monitor PORT's own format, one further registry hop, named at
the call site as a follow-up rather than left implied.

Also documents the one variable name both ends read: the host treats
`PUNKTFUNK_AUDIO_HIRES` as a boolean and the desktop client accepts a rate or a
rate/depth pair, so a box that is both sees two grammars. `1` is the spelling that means
"on" to both.
2026-08-16 11:27:17 +02:00
enricobuehler 63312e0a19 feat(core): the rate divided before it multiplied, which is the whole reason 44.1 kHz was deferred
Lifts the design's §4.1 deferral and admits 44 100 / 88 200 / 176 400 Hz.

`JitterPolicy` and `AvSync` computed `per_ms = rate_hz / 1000 * channels` and then
expressed every figure as `ms * per_ms`. The division came FIRST, so 44 100 became 44
samples per millisecond and every depth, target, shed threshold, hard cap, de-prime
fuse and reported `buffer_ms` was 2.3 % low. 48 000 and 96 000 were exact only because
they happen to divide.

The design prescribed "denominate the policy in samples with a rational `per_ms`". No
rational type was needed: the defect was operator ORDER. Multiplying first and dividing
last is exact at every rate for one integer division per conversion, and 48/96 kHz stay
bit-identical by construction — `per_sec == 1000 × per_ms` exactly there, so both
conversions reduce to the old expression. Every existing policy test passes untouched.

`frame_samples()` now delegates to `pcm::samples_per_frame` rather than re-deriving the
count. At 44 100 Hz those differ: 5 ms of audio is 441 interleaved samples but a 5 ms
FRAME is 440, because 220.5 samples per channel do not exist. The near-miss margin and
the shed both mean "exactly one packet", so a self-derived answer would have described
a packet that does not exist.

`frame_duration_ns` is the new inverse, and it exists because a frame's label and its
duration have come apart. 44 100 divides none of the seven ladder rungs, 88 200 divides
only 5 ms, 176 400 only 5 ms and 2.5 ms — so a rung is a nominal length for the wire and
the ring, never a duration. Anything advancing a timestamp must use the real sample
count or the clock runs 0.23 % fast forever, which the A/V sync loop would fight and
never win.

`MAX_CONCEAL_PACKETS` was a frame count documented as "50 ms at the protocol's 5 ms
frames" — 20 ms at a 2 ms lossless frame. It is now `MAX_CONCEAL_MS` with the count
derived, and `AudioGapTracker` carries the frame the same way `JitterPolicy` and
`DroughtConceal` already do. The ABI conceal buffer sizes its run from the derived cap
while keeping the frame size at the longest rung — mismatched deliberately, and only in
the direction that cannot overrun.

Overflow is handled where the multiply-first order creates it: 480 000 ms at 176 400 Hz
by 8 channels is 6.8e11, which wraps a 32-bit usize before the divide brings it back, so
the intermediates are u64/u128 and saturate rather than wrap — a wrapped window is a
tiny one, i.e. a fuse that blows instantly.
2026-08-16 10:55:55 +02:00
enricobuehler 75eaf9ad91 Merge pull request 'The game library was unreachable in Android's touch UI, and no library view could copy a title's link' (#266) from worktree-library-access-and-copy-link into main
Reviewed-on: unom/punktfunk#266
2026-08-16 08:53:57 +00:00
enricobuehler d5838f381a fix(client-core): a pipewire type in a cfg(any(linux, test)) signature broke the Windows client leg
Main has been red on both Windows client jobs since the pad-audio work landed:

    error[E0433]: cannot find module or crate `pipewire`
       --> crates\pf-client-core\src\pad_audio.rs:368:39
    error: could not compile `pf-client-core` (lib test) due to 1 previous error

`pad_audio.rs` gates its pure-logic helpers `cfg(any(target_os = "linux", test))` so the
sink-picking decisions stay unit-testable on every platform. `sink_from_props` was given
the same gate, but it is not pure logic: it takes a `&pipewire::spa::utils::dict::DictRef`,
and `pipewire` is declared under `[target.'cfg(target_os = "linux")'.dependencies]`.

So on Windows the `test` arm pulls the item into the `lib test` target, where the crate
does not exist. Nothing but `--all-targets` compiles that target, which is why no Linux
gate could see it — including the container gate used to validate the original change,
which lints pf-client-core for the host target only.

The function's sole caller already lives inside a plain `cfg(target_os = "linux")` block
and no test refers to it, so the `test` arm was only ever dead weight. Dropped it.

Swept the rest of the crate for the same shape: of the 13 `cfg(any(target_os = "linux",
test))` items in pad_audio.rs, this is the only one that names a Linux-only crate, and no
other file in pf-client-core uses that gate. All 13 other pipewire references sit under a
plain `cfg(target_os = "linux")`.

Linux codegen is unchanged: `target_os = "linux"` was already true on that arm, so the
item compiles exactly as before there.
2026-08-16 10:50:06 +02:00
enricobuehler 14bdfeaeeb fix(client-windows): the library tile's overflow sat inside its own launch backstop
The poster's new "…" button was a child of the border carrying on_tapped, so
opening the menu was also a click on the tile. host_tile on the hosts page
already splits these — the tapped region is the inner summary and the menu
button is its SIBLING in the grid — and the library tile now does the same.
Relying on Button marking Tapped handled is a guess about event routing where
the shell already has an answer that does not need one.
2026-08-16 10:20:57 +02:00
enricobuehler ae6d872bed test(client-android): shoot the touch library, and pass the shelf's callbacks as lambdas
Two follow-ups on the library work:

* LibraryScreen handed ConsoleLibrary/TouchLibrary `::launch` and `::copyLink`.
  Two callable references to the same local function compare EQUAL however
  different the frame they captured, so a skipped recomposition would have left
  the child calling a closure over stale settings — the trap SettingsScreen
  documents at scopeProfile(), having already been bitten by it. Lambdas
  instead, which is what every other call site here passes.
* The touch grid was a brand-new screen with no visual coverage. TouchGrid goes
  internal (the reason Coverflow already is) and the harness grows a portrait
  scene over the same mock shelf the coverflow scene uses, so the column count,
  the group headings and the launcher brand-mark tile are all captured.
2026-08-16 10:19:53 +02:00
enricobuehler b949bf2062 feat(clients): the game library was unreachable in Android's touch UI, and no library view could copy a title's link
Two gaps, both about the library:

* On Android, the shelf was reachable only with Y from a console tile — a
  finger has no Y, so the touch home had no route to it at all. LibraryScreen
  grows a second presentation (the poster GRID the Apple, GTK and Windows
  shells already draw) and a card's overflow grows "Browse library…", behind
  the same experimental toggle as everywhere else.
* No library view offered "Copy link", although design/client-deep-links.md §5
  names the library game context menu as an attach point beside the host card
  and the pinned card. Every shell now hands out a title's own URL — the same
  self-emitted form a host card gives, plus that game's launch= id, so pasting
  it into Playnite or a Stream Deck macro boots straight into the title.

The gesture follows each surface: a context menu on the touch/mouse grids
(Apple, Android, GTK; Windows uses a tile overflow button, since the reactor
hangs menu_flyout off buttons only), and the X face button plus a legend entry
on the three gamepad coverflows, where there is no right-click.
2026-08-16 10:09:53 +02:00
enricobuehler d8f7984477 fix(core): the drought fuse was spent in 5 ms units on a plane whose frames are 2 ms
`DroughtConceal` charges one frame per concealed frame but bounds itself in WALL-CLOCK
milliseconds, and the two disagreed about how long a frame is. It assumed 5 ms, so on a
2 ms lossless frame the `max_ms` budget ran out after two fifths of the time the tuning
intends, and the `plc_ms` line over-reported concealment by the same factor.

The frame COUNT was always right — it charged 5 and divided by 5 — which is exactly why
this went unnoticed: the load-bearing number was correct and only the two human-facing
ones were wrong. It now counts frames directly and derives both millisecond figures from
the negotiated frame, so `packet()` needs no division at all.

The two thresholds move with it. Both were `2 * FRAME_MS`, expressing "two frames", so
they are now two real frames rather than a fixed 10 ms — on a 2 ms plane the old value
waited five frames before conceding there was a stall.

Both clients pass their resolved frame in, and the desktop client drops the local frame
tally it had to keep while core could not be told.
2026-08-16 10:05:09 +02:00
enricobuehler bee924162a feat(client): the desktop client decoded one plane, at one rate, and read the engine's opinion of neither
Work package H5, client half — Linux and Windows.

`AudioDec` grows a PCM arm behind the same `decode`/`conceal` pair, so the pull loop is
plane-agnostic and `AudioGapTracker` still decides how many frames are missing. The
lossless plane conceals with `PcmConceal` rather than libopus, because a raw frame
carries no decoder model to extrapolate from. A codec the client cannot decode is now
refused out loud and the session runs video-only, instead of a `0xD3` payload being fed
to libopus.

The conceal scratch was `5760 * channels` — 120 ms at 48 kHz, and an overrun at 96 kHz.
It is now 120 ms at the resolved rate for Opus, or exactly one negotiated frame for PCM.

On Windows the engine trap is closed on both sides of the connect. `can_render_at`
reads the render endpoint's own mix format before the handshake and withholds the
capability bit when the engine cannot carry the rate — the client-side twin of the host
rule, and the only point at which declining is still free. `render_thread` reads it
again before initialising and warns if the endpoint moved underneath us. `autoconvert`
would otherwise have silently downsampled a 96 kHz stream on arrival and wasted the
entire exercise while every log line agreed the session was hi-res.

Both graphs stay 32-bit float, deliberately and with the reasoning at the site: core
hands both planes over as f32, so an S24 graph would re-quantise the very samples the
plane exists to deliver unrounded.

§9 asks a client whose device refuses the rate to "say so and fall back". That is not
implementable where the doc puts it — by the time the device opens, the wire format is
fixed by the Welcome and the plane cannot switch mid-session, so falling back would mean
killing audio. The decision moved to where it is still free: gate before connect, report
loudly at open.

PipeWire's `NODE_LATENCY` was the string literal "240/48000"; it is built at runtime now
so the graph quantum stays one protocol frame at any rate, and a new `param_changed`
handler logs what the graph actually granted.

Verified on Linux in a container: check, clippy -D warnings, build and 210 tests, all
green. `audio_wasapi.rs` compiles nowhere available here — macOS cannot build the crate
at all and the MSVC cross target dies in openh264's C build — so Windows CI is its first
compiler.
2026-08-16 09:56:29 +02:00
enricobuehler 45c37f1a3b feat(apple): the ring measured milliseconds in a constant nobody had told about the negotiated rate
Work package H6, Apple half.

The Swift client is the one that PORTS the de-jitter policy rather than draining core's
decoded frames, so every hardcoded 48 in `AudioRing` was a second, silent copy of an
assumption core had already stopped making. `perMS`, the one-second ring capacity, and
`AvSync`'s weight all follow the resolved rate now, and the frame length follows the
resolved `audio_frame_us` through the accessor core grew for exactly this.

That frame length matters in four places, all of which were quietly wrong on a lossless
session: the target floor is a device quantum plus one frame, the smooth shed drops one
frame, the seam crossfade is capped at half of one, and the near-miss margin means "less
than one packet in hand". Frozen at 5 ms against a 2 ms frame the last of those means
two and a half packets, which grows the target on a ring that was never close to
starving. Core has been changed to agree.

Frame geometry is computed in microseconds. 2 500 µs is the one rung on the ladder where
routing through integer milliseconds silently loses a fifth of the frame, and every
other rung is ms-exact — which is precisely what would have let that bug hide.

`setPreferredSampleRate` now runs on every branch (the `.playback` ones, which are
mic-off iOS and all of tvOS, asked for nothing at all before) and runs before
`setActive`, since that is when the hardware is configured. What the route actually
granted is read back afterwards and warned about when it differs.

The graph is deliberately built at the RESOLVED rate rather than the granted one. The
source node's format describes the samples, which arrive at the wire rate; declaring the
device's 48 kHz while writing 96 kHz samples plays the stream at half speed. And a
literal fallback is impossible anyway — the plane never switches mid-session — so the
honest behaviour is to let the engine resample and SAY SO, which is report-not-refuse
rather than the silent resample the design forbids.

Settings gain an audio-format choice defaulting off, stereo-gated, with the same raw
values the Android leg uses so a profile round-trips between the two clients. The HUD
shows the RESOLVED format, because a declined lossless session that still says
"lossless" is the same class of lie as claiming a rate you did not get.
2026-08-16 09:55:42 +02:00
enricobuehler ec1e7be7dd feat(android): AAudio grants a rate or fails, so the fallback had to happen before the Hello
Work package H6, Android half — plus the near-miss margin in core, which the Apple leg
independently proved was measuring the wrong thing.

AAudio never substitutes a rate: an explicit request is granted or the open fails. So a
48 kHz rung sitting below a 96 kHz session on the open ladder could only ever produce
playback at 2x speed, or a resample the design forbids in as many words. The fallback
therefore runs BEFORE the handshake: a probe opens a stream at the requested rate, reads
back what was granted and closes it without ever starting it, so nothing routes and no
audio focus is taken. The ladder is 96 -> 48 keeping the depth -> the legacy pair, so a
device that will not grant 96 kHz still gets a 48/24 lossless session rather than
silence. Only 96 kHz is probed; a default session opens nothing and pays nothing.

The open ladder still gains a rate dimension, because a probe is one measurement at one
instant and the supervisor reopens across route changes. Rate is the outermost
dimension, and the last rung is AAUDIO_UNSPECIFIED for the HAL that refuses an explicit
request while already running at exactly the rate we wanted — `arm` still holds it to
the session's rate, so that rung can rescue a session but never mislabel one.

The conceal scratch is now sized per plane: Opus keeps its 120 ms legal maximum, PCM
uses the longest ladder rung. Sizing both from the Opus figure was 24x too large at
96 kHz, and sizing both from the PCM one would have been far too small for Opus.

The poll tick is one frame of the running plane rather than a fixed 5 ms, so on a 2 ms
session the drought-concealment arm keeps pace with playout instead of losing 3 ms in 5.

In core, the near-miss margin stops being a constant. Its own doc always said "less than
one protocol frame was left in hand", but it was frozen at 5 ms — which against a 2 ms
lossless frame means two and a half frames, so it grew the target on a ring that was
never close to starving, inverting exactly what a near-miss exists to detect. Identical
on every Opus session.

The Android CI lane's test filter is an allowlist and eleven classes sat outside it,
including the audio HUD ones, which had never run in CI at all. All eleven pass, so
nothing was hiding — but nothing was gating either. They are listed now, explicitly
rather than by glob, because the unfiltered task also drags in the screenshot scenes.
2026-08-16 09:55:21 +02:00
enricobuehler b4255631ac feat(host): the gate could not tell whether capture would really deliver the rate it was promising
Closes the one place the implementation and the design did not meet, plus two constants
that still measured time in the Opus frame.

§8.4 says hi-res resolves only if "the capture path can genuinely deliver the rate",
but the capturer is opened by the audio thread, well after `negotiate` builds the
Welcome. The gate could only check that the format was well-formed; the truth arrived
later and the audio thread ended the plane — a silence outcome the design calls
unacceptable.

The rate is now answered before the Welcome, by a probe that opens no stream and
changes nothing about the box. It is three-valued on purpose. Windows reads the
endpoint's own mix format via a read-only rerun of the wiring plan — deliberately NOT
the real wiring pass, which parks defaults, mints endpoints and logs, none of which may
happen mid-handshake for a session that is about to resolve to Opus anyway. Linux needs
no query at all: in stream-sink mode the host declares the format itself, so it is
honest by construction, and monitor mode cannot know, so it declines. Anything else, or
any probe failure, is Unknown and declines too — unlike the wiring plan, where an
unknown format means "assume it's fine", here it means "we cannot prove content matches
label", which is the whole point of the feature.

The audio thread's per-iteration check stays, with its role reduced to the probe-to-open
race — an operator changing the endpoint format, a hotplug re-plan, a graph
renegotiation. Its action is unchanged because there is still only one correct one: the
plane cannot switch to Opus mid-session, and mis-clocked samples under the promised
label is precisely what this feature exists to prevent.

`INFILL_AFTER` and `LATE_DEPARTURE` were both denominated in the 5 ms Opus frame while
documenting themselves as "one protocol frame". On a 2 ms lossless frame the first
spent its 500 ms infill budget in 200 ms of real time and the second let a two-frame
slip read as on-time. Both now derive from the session's resolved frame. `INFILL_MAX`
deliberately stays wall-clock and says why.

`SendStats` lost its `Default` in the process, deliberately: a zero frame makes every
departure compare as late, and since the window is rebuilt on each 30-second flush that
would not even have been a once-per-session mistake.

`PUNKTFUNK_AUDIO_HIRES` is now documented for operators, with the cost stated plainly
and without overselling — the design's own §12 says hi-res is likely inaudible for game
content and that the real win is bit-exactness.
2026-08-16 09:41:38 +02:00
enricobuehler a81c6ac789 feat(core): a ported de-jitter policy had no way to learn the real frame length
Two independent workstreams hit the same wall: `PUNKTFUNK_AUDIO_FRAME_MS` is the Opus
plane's 5 ms, the lossless plane runs 4 ms at 48/24 and 2 ms at 96/24, and nothing in
the C surface carried the resolved value. An embedder that PORTS the de-jitter policy
rather than draining `next_audio_pcm` — the Apple client does exactly this — was stuck
compiling against 5 ms, which sheds 2.5 frames at a time on a 96 kHz session and puts
the target floor a frame too deep.

The earlier judgement that no C embedder needs this rested on `next_audio_pcm`
reporting each frame's real length in `frame_count`. That is not true in the case that
matters: concealed frames are PREPENDED into the same buffer, so the count answers "how
many samples did I get", not "how long is one frame". The two questions coincide only
when nothing was lost.

Microseconds, not milliseconds — the ladder has sub-millisecond rungs and 2 500 µs must
not truncate to 2 ms. `0` means the host stated nothing, in which case the old constant
is right.

Added, not widened, like the rate and depth accessors before it.
2026-08-16 09:38:16 +02:00
enricobuehler 88d29d3615 chore(core): regenerate the C header for the FRAME_MS note
cbindgen picked the doc comment up on the next build; the checked-in header
must not disagree with the source it is generated from.
2026-08-16 09:35:56 +02:00
enricobuehler 099904967d docs(core): PUNKTFUNK_AUDIO_FRAME_MS is the Opus frame, and a ring sized from it is wrong on the lossless plane
`PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ` got a paragraph explaining that it is the default rate
and that hi-res sessions must use the accessor. Its sibling `PUNKTFUNK_AUDIO_FRAME_MS`
got nothing, and it has the identical hazard: the lossless plane negotiates 4 ms at
48/24 and 2 ms at 96/24, so an embedder sizing a playout ring as frames ×
PUNKTFUNK_AUDIO_FRAME_MS is wrong by up to 2.5x with nothing in the C surface saying so.

Both constants are KEPT unchanged — embedders size rings from them and deleting either
is a silent C break. What was missing is the note. No accessor is added because
`next_audio_pcm` already reports each frame's real length in `frame_count`, which is
the figure to size from; the comment now points there.
2026-08-16 09:33:12 +02:00
enricobuehler d09b4668c6 fix(core): the cheapest lossless rung was the one format nobody could ask for
Three follow-ups from the lossless plane.

The hi-res capability bit was derived from "the caller asked for something other than
48 kHz/16-bit". That rule is wrong at exactly one point, and it is a point that
matters: 48/16 is both the default AND the cheapest lossless rung (1.5 Mbps against
Opus's 256 kbps), so it was the one format on the ladder no caller could request. The
bit now means "the caller SPECIFIED a format", with 0 as the unspecified value. No wire
byte and no ABI change — the wire already encodes an explicit 48000/16 identically to
absent, and the capability bit is what carries the intent.

That inverts a default, so every legacy entry point had to move with it: `connect` and
the four pre-v24 `punktfunk_connect_ex*` variants passed an explicit 48000/16 and would
otherwise have started advertising hi-res on behalf of embedders that never asked. They
now pass 0/0, and the comment at each site says why the distinction is load-bearing.

`JitterPolicy` had two decisions denominated in FRAMES rather than milliseconds — the
floor under the effective target, and the smooth shed's one-frame drop — both written
when 5 ms was the only frame this protocol had. The lossless plane negotiates 4 ms at
48/24 and 2 ms at 96/24, where the old constants shed 2.5 frames at a time and faded
across an entire frame. `set_frame_us` fixes both and caps the seam crossfade at half a
frame, because a fade as long as the material it is fading is not a crossfade. It is a
setter rather than a constructor argument for two reasons: the default keeps every Opus
session and all seventeen policy tests bit-identical, and `audio_frame_us` is resolved
by the host and only known after the Welcome — later than the ring is built. Frame
length is computed in microseconds so 2 500 µs does not truncate through integer
milliseconds.

`audio_kbps()` reports what the plane costs, for the stats line §4.6 asks for. It
answers only for PCM, and the answer is exact rather than measured: PCM is
constant-bitrate by construction, so a byte counter would add sampling noise to a
number already known precisely. Opus is VBR with a host-side ladder position, so the
client has no honest figure and says None instead of inventing one.
2026-08-16 09:18:08 +02:00
enricobuehler cef4b849e7 fix(client): a Linux-only helper was gated on test, so the Windows client's clippy lane could not build
`sink_from_props` carries `cfg(any(target_os = "linux", test))` like its neighbours in
this module, but unlike them it takes a `pipewire::` type in its signature — and
`pipewire` is a Linux-only dependency. The bare `test` arm therefore compiles it in
test configuration on EVERY platform, where the crate does not resolve.

It builds green in release, because nothing reaches the function off Linux. It only
fails under `clippy --all-targets`, which builds the lib test target — so the Windows
client's release build passes and its clippy lane goes red. That is how it reached
main.

Its only caller, `walk_graph`, is already `cfg(target_os = "linux")`, and no test calls
it, so the gate simply becomes Linux-only. The neighbouring helpers keep their `test`
arm: their signatures are plain data, which is the whole point of that pattern.

Pre-existing on main and unrelated to the audio work in this branch; fixed here because
it blocks this PR's Windows lane.
2026-08-16 02:19:00 +02:00
enricobuehler 761895ec46 feat(host): capture was pinned to 48 kHz and one oversized frame could silence audio for the whole session
The capture facade, both backends and the encode loop take a rate; the encode loop
gains a PCM branch; and the session resolves which plane it runs at handshake.

`AudioCapturer` grows `sample_rate()` alongside `channels()`, and both backends report
what they ACTUALLY opened rather than what was asked for. That distinction is the
feature: on Windows `AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM` means asking a 48 kHz engine
for 96 kHz succeeds, returns no error, and hands back interpolation — a session that
would log 96 kHz, spend the bandwidth, and carry nothing above 24 kHz. The endpoint's
own mix format was already being read and logged one line before the request; it is
now read BEFORE the format is chosen and the request is capped to it.

The design said to decline outright when the engine is slower. Taken literally that
regresses the shipped Opus path: a 44.1 kHz endpoint is an ordinary Windows
configuration, and declining down to 44,100 would hand libopus a rate it does not
accept. The decline is floored at 48 kHz, so only a hi-res request can lose.

The resolve gate is the five conditions from the design, each declining to Opus with a
logged reason — a fallback is not a failure, but an unexplained one is. Its cost check
compares the plane against the video bitrate because audio rides datagrams outside the
ABR loop: whatever it takes is off the top and ABR can neither see it nor reclaim it.
A request that does not fit declines rather than being quietly handed a cheaper rung.

One condition cannot be answered where the design puts it. "The capture path can
genuinely deliver the rate" is unknowable at handshake, because the capturer is opened
by the audio thread well afterwards. The gate checks format validity; the audio thread
checks the truth every iteration and, on the PCM plane only, ends the plane rather than
shipping mislabelled samples. Ending it is a silence outcome the design calls
unacceptable, and it is confined to a 96 kHz opt-in whose endpoint turns out to run at
48 kHz — the real fix is probing capture before the Welcome, which this pass does not
do.

Separately, a pre-existing bug this feature makes reachable: the audio and pad-audio
send paths treated ANY `send_datagram` error as "connection gone" and tore the plane
down for the rest of the session. Three of quinn's four error variants are not that.
`TooLarge` now counts and drops one frame, `UnsupportedByPeer`/`Disabled` end the plane
once with a reason, and only `ConnectionLost` breaks the session. The counts join the
existing 30-second egress line.

Note that the send BUFFER's drops remain invisible: quinn evicts oldest-first and
returns Ok, so "buffer full" never reaches a caller. The counter here is TooLarge only.

`PUNKTFUNK_AUDIO_HIRES` defaults OFF, unlike `PUNKTFUNK_AUDIO_REDUNDANCY` — this
spends bandwidth the user did not previously agree to, so it is asked for on both ends.

GameStream capture stays 48 kHz permanently and says why at the call site: Moonlight's
protocol is Opus 48 kHz and is not ours to renegotiate.
2026-08-16 02:08:47 +02:00
enricobuehler 435962ba38 feat(core): the client had no way to be told what audio format it was about to receive
C ABI 23 -> 24.

The resolved rate, depth, codec and frame duration now follow the exact path
`audio_channels` already takes — Hello carries the request, Welcome carries what the
host actually resolved, and the client opens its device from the Welcome rather than
from what it asked for. A host may resolve lower than requested; that is the whole
point of "the client asks and the host obliges if it can".

`0xD3` decodes through the same sink as `0xC9`, so nothing downstream of the demux
changes. Concealment does change: a lossless format has no PLC, because there is
nothing in a raw frame from which to synthesise its successor, so the PCM plane uses
`PcmConceal` where the Opus plane calls into libopus.

Two ABI hazards handled by adding rather than widening. `PunktfunkAudioPcm` and
`PunktfunkStats` are repr(C) with no struct_size guard, so a new field in either would
break every C embedder that allocates one by value; the rate and depth arrive through
accessors instead, mirroring `punktfunk_connection_audio_channels`. And
`punktfunk_connect_ex11` is a new entry point rather than a wider `ex10` — `ex5`
through `ex10` are byte-for-byte unchanged and delegate with the 48 kHz/16-bit
defaults, the `next_rumble_cmd2` precedent from ABI 18.

`PUNKTFUNK_AUDIO_SAMPLE_RATE_HZ` is KEPT with its value and meaning unchanged. It is
the default rate, embedders size rings from it, and deleting it would be a silent C
break; hi-res sessions use the accessor.

The conceal buffer is sized once and never reallocated — the embedder is already
holding a pointer into it — so a PCM frame decodes into scratch and is copied in under
a clamp. An oversized or malformed datagram truncates rather than growing the buffer.
A test pins the pointer and length across a 200,000-sample datagram and a 38-packet
loss run.

The hi-res cap is advertised only when the caller asked for a non-default format,
because it means "capable AND turned on" and only the embedder knows whether its
device can open at that format. One consequence worth knowing: 48 kHz/16-bit is
byte-identical to a legacy request, so that particular rung is not reachable through
the parameter pair alone.

Also records, at the line itself, why `datagram_send_buffer_size` is NOT raised for the
lossless plane: it would quadruple the Opus plane's worst-case backlog, the two cannot
be sized separately before the handshake resolves, and quinn evicts oldest-first while
returning Ok — so those drops cannot be counted through the API at all.
2026-08-16 02:08:25 +02:00
enricobuehler 7e97e600c7 feat(wire): the audio plane could not say what format it was carrying, so lossless had no way to be negotiated
The `0xC9` plane's header has no rate, depth or codec field, and `Hello`/`Welcome`
carried only a channel count. A second, lossless plane therefore needs its own tag and
its own negotiation rather than a wider header.

`0xD3` carries interleaved LE PCM behind DELIBERATELY the same 13-byte header as
`0xC9`, so `AudioGapTracker` and the pts/A-V-sync plumbing work unchanged and the only
new logic is the payload and its concealment. One frame per datagram, never
fragmented; the frame duration is chosen at session start from the raw frame size so
it cannot exceed the path MTU. Redundancy is not defined for this plane and is never
sent with it.

`Hello` gains `audio_rate_hz`/`audio_bits` — what the client is asking for. `Welcome`
gains `audio_codec`/`audio_rate_hz`/`audio_bits`/`audio_frame_us` — what the host
actually resolved, which may be lower, and which the client must open its device from
rather than from what it requested.

Two wire traps, both now covered:

`Welcome`'s tail is conditional — `cipher` at 68 and the 32-byte ChaCha key at 69..101
are emitted only for ChaCha — so an appended field lands at 79 under AES and 111 under
ChaCha. Getting that wrong breaks soft-AES clients (webOS) and nothing else, which is
exactly the kind of bug that ships. A test pins both offsets and both total lengths.

`Hello`'s post-HDR tail is capped at 27 bytes, because with no HDR block present the
decoder disambiguates by remaining length and a 28-byte tail would be misread AS an
HDR block. That budget goes from 3 spent to 8; a test pins it.

A default Opus session stays byte-identical on both messages: `Welcome` is still
exactly 68 bytes, `Hello` still 26.

`HOST_CAP_AUDIO_HIRES` takes the LAST free `host_caps` bit. The next host capability
needs a second byte and an ABI bump; the constant says so where someone will read it.
2026-08-16 01:37:19 +02:00
enricobuehler 368cd3c13d feat(audio): the jitter policy took the sample rate from a constant, so a lossless plane had nowhere to put one
H1 of the hi-res programme, plus the PCM payload H2 needs.

`JitterPolicy` and `AvSync` read `SAMPLE_RATE_HZ` directly, so every depth, target,
shed threshold and reported `buffer_ms` was 48 kHz by construction. Both now take the
rate explicitly via `new_at_rate`; `new` delegates at the protocol default, so all
three production callers and the seventeen policy tests keep byte-identical behaviour.

The rate is denominated in integer samples per millisecond and stays that way. 48 and
96 kHz are exact; 44 100 truncates to 44 samples/ms, a silent 2.3% error in every
figure the policy computes. That deferral now has a `debug_assert` tripwire rather
than a comment, so adding a rate the arithmetic cannot represent is loud.

`audio::pcm` is the second plane's payload: interleaved LE integer samples, no codec.
FLAC was the obvious choice and does not earn its place here — a datagram over the
path MTU is not sent at all and this plane is never fragmented, so frame duration must
be sized from the worst case, and FLAC's worst case is a VERBATIM subframe: raw plus a
header. It would negotiate the same frame duration, the same packet rate and the same
send-buffer sizing, buying only average bytes on a plane that is provisioned for peak
because it rides outside the ABR loop. At 24 bits from an undithered float mix it
saves least of all, and it would cost a spike gate plus a dependency on five packaging
targets.

The frame ladder is sized from raw bytes, which fixes a latent bug in the design: the
doc's table sized 96/24 at 2.5 ms from a *coded* estimate of ~1000 B, but the raw frame
is 1440 B and does not fit a 1472-byte-ceiling datagram. Under FLAC that would have
been a verbatim frame that silently failed to send. A test pins it.

Bit-exactness is proven rather than asserted: every representable code at both depths
survives wire to f32 and back unchanged.
2026-08-16 01:15:32 +02:00
478 changed files with 62303 additions and 14087 deletions
+6
View File
@@ -0,0 +1,6 @@
<!-- What and why — the diff says how. -->
**User-facing fact changed?** (an install step, a knob, a port, what a feature does, a limit)
→ the docs-site page that owns it is updated in this PR, or this is n/a. Install/repo/port facts
live in `data/platforms.json`. (CONTRIBUTING.md "Where facts live"; `docs-drift` in CI only
catches the mechanical half.)
+32
View File
@@ -94,6 +94,14 @@ env:
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
# The Skia console (pf-console-ui over skia-bindings, design/android-skia-console-port.md):
# prebuilt Skia archives for all three Android ABIs come from our own release
# (git.unom.io/unom/skia-binaries, tag = the skia-bindings version) — kit/build.gradle.kts
# bakes that url template in as the default, so this workflow needs no configuration. The
# repo variable, when set, OVERRIDES it (e.g. to stage the next skia-safe bump's archives
# before they are released). 🛑 skia-bindings never fails when no archive matches — it
# silently builds Skia from source. Every ABI's log must show `DOWNLOAD AND INSTALL SUCCEEDED`.
SKIA_BINARIES_URL: ${{ vars.SKIA_BINARIES_URL }}
jobs:
android:
@@ -222,6 +230,19 @@ jobs:
# screenshot scenes, which are a release-artifact job (android-screenshots.yml, gated to v*
# tags) and have no business adding a minute to every push. The filter is what lets the
# contract gate here without dragging the rest of the app suite in with it.
#
# ⚠ The filter is an ALLOWLIST, so a test class that is not named here does not run — it
# reads as coverage in the tree and gates nothing. `ProfilesTest` and `StatsOverlayAudioTest`
# sat outside it and were only noticed when the hi-res audio work added cases to both; the
# HUD ones had never run in CI at all. Adding a test class to `app/src/test` is therefore
# only half the job: add it here too, or it is decoration.
#
# That audit found NINE more in the same state (gamepad palette/rows/UI, OS icons, render
# scale, safe area, SC2 bluetooth grant, settings scope, speed test) — every one of them
# passing, so nothing was hiding, but none of them gating either. They are all listed now.
# The list is deliberately explicit rather than a package glob: the unfiltered task also
# drags in the ~20 Roborazzi screenshot scenes above, and a glob would quietly re-admit them
# the moment someone added one.
- name: console parity vectors + app-module logic tests
working-directory: clients/android
run: >-
@@ -231,6 +252,17 @@ jobs:
--tests 'io.unom.punktfunk.GamepadSettingsLayoutTest'
--tests 'io.unom.punktfunk.ConsoleSubScreenRowsTest'
--tests 'io.unom.punktfunk.ConsoleSubScreenRoutesTest'
--tests 'io.unom.punktfunk.ProfilesTest'
--tests 'io.unom.punktfunk.StatsOverlayAudioTest'
--tests 'io.unom.punktfunk.GamepadPaletteTest'
--tests 'io.unom.punktfunk.GamepadSettingsRowsTest'
--tests 'io.unom.punktfunk.GamepadUiTest'
--tests 'io.unom.punktfunk.OsIconsTest'
--tests 'io.unom.punktfunk.RenderScaleTest'
--tests 'io.unom.punktfunk.SafeAreaTest'
--tests 'io.unom.punktfunk.Sc2BluetoothGrantTest'
--tests 'io.unom.punktfunk.SettingsScopeTest'
--tests 'io.unom.punktfunk.SpeedTestTest'
--stacktrace
- name: assembleDebug (cargo-ndk → jniLibs → APK)
+3 -1
View File
@@ -248,7 +248,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: The console must exist (cache hit or fresh build)
+52 -2
View File
@@ -175,6 +175,19 @@ jobs:
- name: Test (unit + loopback + proptest + C ABI harness)
run: cargo test --workspace --locked
# The deep half of the docs-drift gates (the `docs-drift` job checks the docs-site copy
# and the textual rest): the committed spec must match what the binary actually serves.
# Build already compiled punktfunk-host with default features, so this re-links at worst.
# Byte diff on purpose — the generator is deterministic, and if that ever stops being
# true it deserves to surface here.
- name: OpenAPI spec drift gate
run: |
cargo run -p punktfunk-host --locked -- openapi > /tmp/openapi.regen.json
diff -u api/openapi.json /tmp/openapi.regen.json >/dev/null || {
echo "::error::api/openapi.json is stale — regenerate: cargo run -p punktfunk-host -- openapi > api/openapi.json && cp api/openapi.json docs-site/public/openapi.json"
exit 1
}
# The GPU encode backends are OFF by default, so every step above compiles ~none of them:
# `nvenc` gates enc/linux/nvenc_cuda.rs (+ nvenc_core/nvenc_status) and `vulkan-encode` gates
# enc/linux/vulkan_video.rs (+ the vendored vk_av1_encode/vk_valve_rgb bindings) — ~8,150
@@ -326,8 +339,19 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs
- uses: actions/checkout@v4
# RETRIED, like every other single-shot network call in CI (scripts/ci/retry.sh's header
# has the why: this box runs many jobs in parallel and drops packets under that load).
# `bun install` streams download-and-extract, so a tarball truncated mid-stream surfaces
# as `error: Fail extracting tarball for "<pkg>"` — which reads like a corrupt package and
# is not one. Measured 2026-08-20: run 19630's docs-site died that way on
# @rolldown/binding-linux-x64-gnu (8.3 MB) while the web job installed the same registry
# in the same run, and run 19632 installed the identical lockfile seven minutes later. The
# tarball's sha512 matches the lockfile and both bun 1.3.13 and 1.3.14 extract it from
# disk, so there was never anything wrong with the package. 3 attempts (10s+20s backoff),
# not retry.sh's usual 5: a genuinely stale lockfile fails deterministically here, and
# 30s is enough to ride out a load burst without making that wait a minute and a half.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: it generates the orval API client + paraglide messages that
# typechecking imports.
- name: Build
@@ -355,8 +379,9 @@ jobs:
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# Retried — see the web job above; this is the job the flake was measured on.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# Build first: fumadocs-mdx emits the .source typegen the typecheck imports.
- name: Build
run: bun run build
@@ -390,3 +415,28 @@ jobs:
# schema stability across bun2nix releases). Fix with: scripts/ci/check-bun-nix.sh --fix
- name: bun.nix drift gate
run: sh scripts/ci/check-bun-nix.sh
# Docs drift gates — pure git-grep textual checks, no cargo, no bun install (the deep half,
# regenerating the OpenAPI spec from the built host, rides in the `rust` job above). Same
# reasoning as bun-nix for being UNFILTERED: docs drift arrives through commits that look
# unrelated to docs — a renamed env var, a removed subcommand, a moved page.
docs-drift:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
steps:
# oven/bun ships neither git nor a real node, and the slim base has no CA bundle —
# actions/checkout needs all three (see the web job).
- name: Install git + node + CA certs
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git nodejs
- uses: actions/checkout@v4
# OpenAPI snapshot in sync, PUNKTFUNK_* vars in docs still exist, undocumented-var
# ratchet (baseline: scripts/ci/docs-undocumented-env-baseline.txt), host-cli.md commands
# still exist, data/platforms.json parses.
- name: Docs drift gates
run: sh scripts/ci/check-docs-drift.sh
# Internal links only: /docs/* page links in docs-site content, relative file links in
# the repo's markdown. External URLs and #anchors are deliberately not checked.
- name: Docs link check
run: sh scripts/ci/check-docs-links.sh
+3 -1
View File
@@ -231,7 +231,9 @@ jobs:
# scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job
# neither consumes nor commits, whose only effect here is to make the install depend on
# bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless.
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
if ! grep -q 'Bun\.serve' .output/server/index.mjs; then
echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1
+59 -13
View File
@@ -1,17 +1,23 @@
# Deploy-only: bring up the two unom-1 pieces that live in THIS repo but whose normal
# deploys are coupled to heavy build workflows — docs to docker.yml's 5-image matrix,
# the flatpak server to flatpak.yml's full flatpak-builder run. This workflow does
# NEITHER build: it just (re)places the compose files and pulls the already-published
# images, so unom/infra's deploy-all can bring a fresh unom-1 fully up in a single
# dispatch without triggering those rebuilds.
# Deploy-only: bring up the unom-1 pieces that live in THIS repo but whose normal deploys
# are coupled to heavy build workflows — docs to docker.yml's 5-image matrix, the flatpak
# server to flatpak.yml's full flatpak-builder run, the nix cache to nix.yml's full Rust
# build. This workflow does NONE of those builds: it just (re)places the compose files and
# pulls the already-published images, so unom/infra's deploy-all can bring a fresh unom-1
# fully up in a single dispatch without triggering those rebuilds.
#
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
# brings it up on :3220.
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
# on the next client build, or restored from the unom-1 backup
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
# an empty repo until then; that is expected.
# docs -> pulls git.unom.io/unom/punktfunk-docs:latest (built by docker.yml) and
# brings it up on :3220.
# flatpak -> brings up the caddy:2-alpine static server on :3230. The OSTree repo
# CONTENT (./site) is NOT shipped here — it is regenerated by flatpak.yml
# on the next client build, or restored from the unom-1 backup
# (unom/infra scripts/restore-unom-1.sh, `files` tag). A fresh box serves
# an empty repo until then; that is expected.
# nix-cache -> brings up the caddy:2-alpine Nix binary cache on :3250. Same content/config
# split: the cache CONTENT is republished by nix.yml on the next main push
# that moves the flake. An empty cache is harmless — every path 404s and
# users build from source, which is the pre-cache status quo.
# winget -> brings up the winget REST source on :3240; catalogue shipped by
# windows-host.yml on stable tags.
#
# Dispatched by unom/infra scripts/deploy-all.sh: `dispatch-and-wait.sh punktfunk
# deploy-services.yml`. Uses the same secret set docker.yml/flatpak.yml already rely on:
@@ -100,6 +106,46 @@ jobs:
cd ~/unom-flatpak
docker compose -f compose.production.yml up -d
nix-cache:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Sync nix cache compose + server
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
# Land all three flat in ~/unom-nix-cache/ (drop the packaging/nix/server/ prefix).
source: "packaging/nix/server/compose.production.yml,packaging/nix/server/Caddyfile,packaging/nix/server/prune.sh"
target: "~/unom-nix-cache"
strip_components: 3
overwrite: true
- name: Start nix binary cache server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
port: ${{ secrets.DEPLOY_PORT }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -euo pipefail
# ./site (the cache CONTENT) is NOT shipped here — nix.yml rsyncs it on each main push
# that moves the flake, same content/config split as the flatpak repo and the winget
# catalogue. Ensure the bind-mount source exists so caddy starts; an empty cache is
# harmless, it just 404s every path and users build from source as they do today.
mkdir -p ~/unom-nix-cache/site/nar
cd ~/unom-nix-cache
docker compose -f compose.production.yml up -d
# A cache that 404s a miss is healthy; one that cannot answer at all is not.
sleep 3
curl -fsS http://127.0.0.1:3250/nix-cache-info \
|| echo "NOTE: no cache content yet - push to main with the flake touched to populate it"
winget:
runs-on: ubuntu-24.04
timeout-minutes: 10
+7
View File
@@ -118,6 +118,13 @@ jobs:
# arch.yml runs in it; ~1 GB of per-run pacman traffic became image layers.
- image: punktfunk-arch-ci
dockerfile: ci/arch-ci.Dockerfile
# Flatpak builder (flatpak + flatpak-builder + ostree + the GNOME 50 runtime/SDK
# and the rust-stable/llvm20 extensions) — flatpak.yml runs in it. ~5 min of
# per-run dnf plus a 168 s restore of a 1.5 GB runtime cache became image layers;
# the ci/ tree is also where the runtime pins now live, so a GNOME bump in the
# manifest means bumping this Dockerfile's ARGs in the same commit.
- image: punktfunk-flatpak-ci
dockerfile: ci/flatpak-ci.Dockerfile
steps:
- uses: actions/checkout@v4
+124 -65
View File
@@ -68,7 +68,18 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
container:
# Fedora ships a recent flatpak + flatpak-builder + the kernel userns support.
# ci/flatpak-ci.Dockerfile — Fedora 43 with flatpak + flatpak-builder + ostree + node,
# and the manifest's Flathub runtime set already installed into /root/.local/share/flatpak.
# MEASURED on run 18855: the two dnf steps this replaces cost 303 s and the actions/cache
# restore of those runtimes another 168 s, on a job whose actual compile is ~6 min.
#
# ⚠ BOOTSTRAP (same rule as every other LAN builder — see docker.yml's header): the
# registry must already hold :latest. The commit that introduced this image also touches
# this file, so both workflows fire together on that one merge and this job can lose the
# race to docker.yml's push. That first run fails on the image pull; re-run it once
# docker.yml is green. Nothing self-heals it here — a `dnf install` fallback would only
# paper over a stale image, and the container never starts to run one anyway.
#
# --privileged is required for bubblewrap inside the Docker executor (see header).
#
# --network host is what finally fixed the years-long "Could not resolve
@@ -81,7 +92,7 @@ jobs:
# default bridge failed too, while the host netns — no embedded resolver in the
# path at all — works every time). Host networking also means this job no
# longer needs the nsswitch surgery below to be lucky.
image: fedora:43
image: 192.168.1.58:5010/punktfunk-flatpak-ci:latest
options: --privileged --network host
steps:
# DNS fix — MUST run before any network step. fedora:43's nsswitch.conf is
@@ -94,11 +105,12 @@ jobs:
# was masked as an intermittent "busy runner drops DNS" and papered over
# with retry.sh — but it's deterministic on a runner where the resolve
# module tips that way (surfaced when jobs began landing on home-runner-2).
# Drop the `resolve` entry so host lookups use plain `dns`. NOTE: this alone is not
# sufficient — the Tooling step's dnf install pulls a systemd package upgrade whose RPM
# trigger re-runs authselect and regenerates this file, undoing the fix. It's reapplied
# there, right before the first `flatpak` network call.
- name: Fix container DNS (drop nss-resolve)
# Drop the `resolve` entry so host lookups use plain `dns`. The image already
# ships it fixed (and, unlike this job, applies the sed AFTER its last dnf — the
# whack-a-mole that needed a second copy in the old Tooling step, because a systemd
# upgrade's authselect trigger regenerates the file). Kept as a cheap idempotent
# guard for a :latest that lags a ci/ change.
- name: Fix container DNS (drop nss-resolve — baked, this is a guard)
run: |
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# History: this step used to ALSO force glibc onto TCP DNS (`options use-vc`) because
@@ -114,27 +126,30 @@ jobs:
# genuine upstream blips.
cat /etc/resolv.conf || true
# fedora:43 has no node, but actions/checkout (a JS action) needs it. A plain `run:` step
# executes via the container shell (no node needed), so install node BEFORE checkout.
- name: node for the JS actions
run: dnf -y install nodejs
# node comes from the image now (act_runner execs a JS action with the CONTAINER's
# node and injects none of its own), so checkout needs no install step ahead of it.
- uses: actions/checkout@v4
- name: Tooling
- name: Tooling (baked — assert, don't install)
run: |
# flatpak-cargo-generator.py (master) needs aiohttp + tomlkit (NOT the old `toml`).
# gnupg2/rsync/openssh-clients: sign the OSTree repo + rsync it to unom-1 (see the deploy step).
dnf -y install flatpak flatpak-builder git python3 python3-aiohttp python3-tomlkit curl jq \
gnupg2 rsync openssh-clients
# Belt-and-suspenders: keep nsswitch on plain `dns` even if this dnf transaction pulled
# in a fresh systemd-resolved (it does — flatpak recommends xdg-desktop-portal ->
# pipewire/wireplumber -> systemd-networkd/-resolved). Verified on the real runner
# (2026-07-11) this dnf install does NOT actually rewrite /etc/nsswitch.conf — no
# authselect trigger fires — so this line alone was never the fix for the failures
# below. See the retry.sh bump for the real cause.
sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf
# A GUARD, not an install. Everything below used to be dnf'd here (303 s/run with the
# node step); it now lives in ci/flatpak-ci.Dockerfile. If :latest ever lags a change to
# that file the fix is to let docker.yml republish it — dnf-ing on top of a stale image
# would hide the drift and cost the time this image exists to save. Fail loudly instead.
for t in flatpak flatpak-builder ostree git python3 gpg rsync ssh curl jq node; do
command -v "$t" >/dev/null \
|| { echo "::error::$t is missing from punktfunk-flatpak-ci — docker.yml must republish :latest"; exit 1; }
done
python3 -c 'import aiohttp, tomlkit' \
|| { echo "::error::flatpak-cargo-generator.py's deps (aiohttp/tomlkit) missing from the image"; exit 1; }
# The runtimes are baked too, but a miss here is survivable: the prefetch step below
# pulls whatever is absent from Flathub, retried. Warn rather than fail — silently
# paying ~1.5 GB per run is the failure mode worth naming.
flatpak list --user --columns=ref | grep -q . \
|| echo "::warning::no Flathub runtimes in the image — the prefetch step will download them (~1.5 GB)"
# Flathub provides the GNOME runtime/SDK + the rust-stable and llvm20 extensions.
# The remote is baked as well; this stays because it is a no-op when present and the
# one network call cheap enough to keep as a guard.
#
# ROOT CAUSE (confirmed 2026-07-11 by watching a live run on home-runner-1): this is
# NOT a deterministic nsswitch/DNS-config bug. gitea-runner-fleet on home-runner-1 is
@@ -153,17 +168,12 @@ jobs:
git config --global --add safe.directory "$PWD"
# This job was the fleet's single heaviest network consumer: every run re-downloaded
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and
# every crate source. Both live in well-defined directories, both are idempotently
# verified/extended by the steps below, and the central cache server restores them
# at LAN speed — so cache them. Keyed on what actually pins them: the manifest tree
# (runtimes/extensions) and manifest+Cargo.lock (crate sources + builder state).
- name: Cache Flathub runtimes
uses: actions/cache@v4
with:
path: ~/.local/share/flatpak
key: flatpak-runtimes-${{ hashFiles('packaging/flatpak/**') }}
restore-keys: flatpak-runtimes-
# the GNOME runtime + SDK + llvm/rust extensions (multi-GB from Flathub) and every
# crate source. The runtimes were cached here from ~/.local/share/flatpak until
# 2026-08-17 and are now IMAGE LAYERS instead (ci/flatpak-ci.Dockerfile). That cache
# is not merely redundant now, it is harmful: restoring it would spend 168 s
# overwriting the baked installation with an older copy of itself. The crate sources
# stay cached — they are keyed on Cargo.lock, which no image can pin.
- name: Cache flatpak-builder state (crate sources, ccache)
uses: actions/cache@v4
with:
@@ -215,39 +225,46 @@ jobs:
# repo therefore produces a single-branch summary that CLOBBERS the other channel on the
# server — the exact bug that made `app/io.unom.Punktfunk/x86_64/stable` unresolvable
# ("No such ref") after a canary main-push overwrote the post-release summary, even though
# the stable commit's objects were still on disk. Fix: mirror the published repo DOWN first,
# so the local repo carries every existing branch; the build below then only ADDS this run's
# commit and the regenerated+signed summary keeps both channels. No-op on a fresh repo (first
# publish) or when the deploy secrets aren't set (the build still produces a valid bundle).
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
# the stable commit's objects were still on disk. Fix: seed every published channel into
# the local repo first; the build below then only ADDS this run's commit and the
# regenerated+signed summary keeps both channels.
#
# HOW, and why not the rsync this replaces. Mirroring the whole published repo down over
# ssh transferred the entire thing every run, because the local repo starts empty and has
# nothing to diff against — rsync said so itself on run 18855:
# received 3,835,169,389 bytes … total size is 3,845,084,524 speedup is 1.00
# 3.84 GB and 180 s off a Hetzner box, to publish a ~28 MB commit, growing by that much
# again every build (the repo is never pruned and the upload runs without --delete). What
# the summary actually needs is each channel's TIP, so pull exactly that over HTTP:
# `--depth=0` takes the requested commit and none of its parents, and `--mirror` writes
# refs/heads/* where build-update-repo looks for them. The current channel's tip is worth
# having for a second reason — it is the new commit's parent, so --generate-static-deltas
# can still emit the from-parent delta that makes `flatpak update` incremental.
#
# This needs no deploy secret at all (it reads the public repo), so unlike the ssh version
# it also seeds correctly on a fork or a secretless run. gpg verification is off for the
# same reason the rsync had none: every object pulled here is re-signed by the deploy step
# below before it is republished.
run: |
set -euo pipefail
if [ -z "${DEPLOY_HOST:-}" ] || [ -z "${DEPLOY_SSH_KEY:-}" ]; then
echo "::warning::DEPLOY_* not set — no seed; building a fresh single-branch repo."
exit 0
fi
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
mkdir -p "$PWD/repo"
# Pull the currently-published repo (all channels' objects + refs) into the repo the build
# will extend. No --delete: the local repo starts empty, so this only ADDS.
# Probe first (retried) whether a published repo exists at all: ONLY that case may
# continue with a fresh repo. A transient network failure must FAIL the job instead —
# a blanket `rsync || continue` here is exactly how a flaky link produces the
# single-branch summary that clobbers the other channel (the bug described above).
PRESENT=$(bash scripts/ci/retry.sh 5 $SSH "$DEST" \
"[ -d $DEPLOY_DIR/site/repo/refs ] && echo present || echo absent")
if [ "$PRESENT" = present ]; then
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" \
"$DEST:$DEPLOY_DIR/site/repo/" "$PWD/repo/"
ostree --repo="$PWD/repo" init --mode=archive
ostree --repo="$PWD/repo" remote add --if-not-exists --no-gpg-verify unom "$REPO_URL/repo/"
# Probe (retried) whether a published repo exists at all: ONLY a real 404 may continue
# with a fresh repo. A transient network failure must FAIL the job instead — treating a
# flaky link as "nothing published yet" is exactly how a single-branch summary comes to
# clobber the other channel (the bug described above).
if bash scripts/ci/retry.sh 5 curl -fsS -o /dev/null "$REPO_URL/repo/summary"; then
for ref in $(bash scripts/ci/retry.sh 5 ostree --repo="$PWD/repo" remote refs unom); do
case "$ref" in unom:app/$APP_ID/x86_64/*) ;; *) continue ;; esac
bash scripts/ci/retry.sh 5 ostree --repo="$PWD/repo" pull --mirror --depth=0 \
unom "${ref#unom:}"
done
elif [ "$(curl -sS -o /dev/null -w '%{http_code}' "$REPO_URL/repo/summary")" = 404 ]; then
echo "::warning::no published repo at $REPO_URL (first publish) — continuing fresh"
else
echo "::warning::no published repo on the server (first publish) — continuing fresh"
echo "::error::$REPO_URL/repo/summary unreachable — refusing to build a summary that would drop a channel"
exit 1
fi
echo "seeded refs:"; ls "$PWD/repo/refs/heads/app/$APP_ID/x86_64/" 2>/dev/null || echo " (none)"
@@ -263,24 +280,52 @@ jobs:
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
# after a partial failure is safe and cheap.
# --disable-rofiles-fuse is the container-safe path (no FUSE).
# --disable-updates ("only download missing sources, never update to latest vcs
# version") is what makes a restored .flatpak-builder cache actually save network:
# every `type: git` source in the manifest is pinned to a COMMIT SHA (gamescope, glm,
# stb — plus gamescope's submodules, pinned by their gitlinks), so there is nothing to
# update to and re-fetching them only buys upstream flakiness. See the build step below
# for the mechanism.
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
# load-sensitive runner, same flathub.org resolution path.
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--install-deps-from=flathub --install-deps-only \
"$PWD/build-dir" "$MANIFEST"
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
--download-only \
--download-only --disable-updates \
"$PWD/build-dir" "$MANIFEST"
- name: Build the flatpak (offline — deps + sources prefetched above)
run: |
# Everything is already local (state dir warmed by the prefetch step), so this long
# step needs no network; --install-deps-from stays as a no-op safety net.
#
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
# build (builder-main.c calls builder_manifest_download() unconditionally — only
# --disable-download skips it), and it passes `update_vcs = !--disable-updates`. With
# updates on (the default) builder_git_mirror_repo() ALWAYS does a live `git ls-remote`
# + `git fetch` for every git source and every submodule, even ones pinned to an
# immutable commit sha. So this step re-fetched five repos on each run — gamescope plus
# its wlroots / libliftoff / vkroots / libdisplay-info submodules — outside retry.sh,
# and one HTTP 503 from gitlab.freedesktop.org killed the job minutes in:
# Fetching git repo https://gitlab.freedesktop.org/emersion/libdisplay-info, ref refs/tags/0.3.0
# error: RPC failed; HTTP 503 … fatal: expected 'acknowledgments'
# Failed to download sources: module gamescope-wsi-layer: … exited with code 128
# (Those three submodules are not even built — the module sets enable_gamescope=false;
# they get mirrored only because flatpak-builder clones submodules by default.)
# With the flag, builder_git_mirror_repo() short-circuits on `git cat-file -e <commit>`
# against the warm mirror and returns BEFORE any network call, so an upstream blip can
# no longer reach this step. It can never change what is built either: every git source
# here is commit-pinned (see the manifest), so "don't update" is a semantic no-op.
# Anything genuinely missing still downloads, so a cold state dir self-heals.
#
# --default-branch=$FLATPAK_BRANCH pins the ref to app/io.unom.Punktfunk/x86_64/<branch>
# (canary or stable) so the matching hosted .flatpakref resolves deterministically
# (manifest sets no branch).
flatpak-builder --user --force-clean --disable-rofiles-fuse \
--default-branch="$FLATPAK_BRANCH" \
--disable-updates \
--install-deps-from=flathub \
--repo="$PWD/repo" \
"$PWD/build-dir" "$MANIFEST"
@@ -402,6 +447,20 @@ jobs:
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" packaging/flatpak/server/compose.production.yml packaging/flatpak/server/Caddyfile "$DEST:$DEPLOY_DIR/"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
echo "deployed → $REPO_URL/${APP_ID}.flatpakref"
# 4) Bound the published repo. Every canary adds ~28 MB that nothing ever removed —
# the upload runs without --delete (deliberately: see above) and the local repo is
# no longer a full mirror that could carry a deletion over. Left alone it had
# reached 3.84 GB, on a box that has run out of disk before. `ostree prune` is the
# safe tool for it: --refs-only touches ONLY commits no ref points at (superseded
# canaries), and --keep-younger-than spares anything recent, so a client mid-pull
# or a box a few builds behind still resolves every object it asks for. Guarded on
# ostree existing there, and never allowed to fail the deploy — the bundle and the
# repo are already published by this point, and a full disk is a slower problem
# than a red release.
bash scripts/ci/retry.sh 3 $SSH "$DEST" \
"command -v ostree >/dev/null && ostree --repo=\$HOME/$DEPLOY_DIR/site/repo prune --refs-only --keep-younger-than='30 days ago' \
|| echo 'no ostree on the deploy host — repo not pruned'" \
|| echo "::warning::prune step failed — published repo may be growing unbounded"
- name: Attach bundle to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
+69
View File
@@ -0,0 +1,69 @@
# Smoke test for the guided installer (scripts/install.sh, docs-and-onboarding overhaul WP4).
# Runs the script unattended inside a clean container per package family against the REAL
# package registry — the one path a textual gate can't cover: does the repo line, the key import
# and the install actually work today on a fresh box. `--no-start` because a container has no
# user systemd; the script degrades to printing the enable command, which is also under test.
#
# Path-filtered on purpose: it pulls ~100 MB of packages per family, so it runs when the script
# or its fact source changes, not on every push (check-docs-drift.sh gate 6 covers the cheap
# half — the install lines in the script must match data/platforms.json verbatim — on every push).
name: installer-smoke
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
pull_request:
paths:
- scripts/install.sh
- data/platforms.json
- .gitea/workflows/installer-smoke.yml
jobs:
smoke:
name: smoke (${{ matrix.family }})
runs-on: ubuntu-24.04
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# actions/checkout needs git + node + CA certs in the container; curl is the
# script's own prerequisite (it says so and stops without it).
- family: debian-13
image: debian:trixie
prep: apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl git nodejs
- family: fedora-44
image: fedora:44
prep: dnf install -y -q curl git nodejs
- family: arch
image: archlinux:base
prep: pacman -Sy --noconfirm --needed curl git nodejs && (pacman-key --init >/dev/null 2>&1 || true)
container:
image: ${{ matrix.image }}
steps:
- name: Prepare the container (${{ matrix.family }})
run: ${{ matrix.prep }}
- uses: actions/checkout@v4
# No tty → the script runs as --yes; --no-start because there is no user systemd here.
# Root without sudo → the script's sudo shim, another path under test.
- name: Run the installer unattended
run: sh scripts/install.sh --yes --no-start
- name: The host is installed and conflict-free
run: |
punktfunk-host --version
punktfunk-host detect-conflicts
- name: Re-running is a no-op install
run: sh scripts/install.sh --yes --no-start | grep -q 'already installed'
- name: --uninstall takes the packages and the repo off again
run: |
sh scripts/install.sh --yes --uninstall
! command -v punktfunk-host
! test -e /etc/apt/sources.list.d/punktfunk.list -o -e /etc/yum.repos.d/punktfunk.repo
! grep -q '^\[punktfunk\]' /etc/pacman.conf 2>/dev/null
@@ -111,3 +111,20 @@ jobs:
name: punktfunk-linux-client-screenshots
path: clients/linux/screenshots
retention-days: 30
# The artifact above is browser-only (Gitea's API doesn't serve v3 artifacts), which
# blocked reusing these shots for the docs. Publish them to the generic package registry
# too — fixed version `ci`, delete-then-PUT so each run overwrites, anonymous GET on a
# public repo:
# https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci/<scene>.png
- name: Publish screenshots to the package registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
BASE="https://git.unom.io/api/packages/unom/generic/punktfunk-linux-client-screenshots/ci"
for f in clients/linux/screenshots/*.png; do
name=$(basename "$f")
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE "$BASE/$name" || true
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" --upload-file "$f" "$BASE/$name"
echo "published $BASE/$name"
done
+187 -19
View File
@@ -4,8 +4,9 @@
# `nix build .#punktfunk-web` was broken for 553 commits before anyone noticed (see the bun-nix job
# in ci.yml for that story).
#
# Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push:
# Three tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would
# run for an hour on every push — so the two cheap tiers gate every PR and the expensive one runs
# only where its cost buys something (a published cache):
#
# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell
# without building them. Catches the failures that actually happen to this flake — a
@@ -32,15 +33,28 @@
# covers what the ci.yml drift gate cannot, e.g. a tarball the registry no longer
# serves, or the codegen going quietly message-less (see packages.nix's inlang note).
#
# The Rust packages (punktfunk-host, punktfunk-client) and punktfunk-gamescope are NOT built here.
# They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build
# them by hand on a Nix box, or with the `build-rust` dispatch input below.
# * cache — PUSH TO MAIN ONLY. Builds the Rust packages + gamescope for real and publishes every
# punktfunk store path to the binary cache at https://nix.unom.io, so a NixOS user gets
# prebuilt binaries instead of an hour of rustc and a gamescope compile. This is the
# expensive tier and it is why the job timeout is 180 rather than 90.
#
# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS
# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while
# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs
# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to
# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump.
# It needs NO extra trigger for releases: a release bumps the workspace version in
# Cargo.toml, which is already in the path filter below, so the tag's content is
# published by the version-bump commit on main.
#
# Only OUR paths are published — see the step for why that is both correct and the
# difference between ~300 MB and several GB per publish.
#
# The Rust packages and punktfunk-gamescope are still not built on PRs: they are the expensive ones
# and their inputs are already gated by the `rust` job in ci.yml. Build them on a PR by hand on a
# Nix box, or with the `build-rust` / `build-gamescope` dispatch inputs below.
#
# ⚠ punktfunk-gamescope matters more than it looks: `host.gamescopeHdr` DEFAULTS TRUE, so it is on
# the critical path of every `services.punktfunk.host.enable = true` build. It patches whatever
# gamescope the pinned nixpkgs carries, so a nixpkgs bump — not a change of ours — is what breaks
# it, and the first person to find out would be an operator whose system rebuild fails. The `cache`
# tier now compiles it on every main push, so a flake.lock bump that breaks it goes red HERE; the
# dispatch input below is for checking it on a branch before merging.
#
# ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest
# breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it.
@@ -107,8 +121,15 @@ jobs:
# real node (so actions/checkout works with no pre-checkout install dance), and audit.yml
# already pulls it on this fleet, so it is proven to resolve here. Nix is installed below.
image: node:22-bookworm
timeout-minutes: 90
# 180, not 90: the `cache` tier on a main push compiles the whole Rust workspace AND gamescope
# from source inside the nix sandbox, where the sccache every other Rust job leans on cannot
# reach (no network in a derivation, and RUSTC_WRAPPER is not set inside one).
timeout-minutes: 180
env:
# Where the published cache lives on unom-1, and the URL users substitute from. Kept next to
# the flatpak repo (3230) and winget source (3240) — see packaging/nix/server/.
DEPLOY_DIR: unom-nix-cache
CACHE_URL: https://nix.unom.io
# The flake needs both experimental features. Also baked into the installer's --extra-conf
# below; this covers any step that shells out before that config is read.
NIX_CONFIG: "experimental-features = nix-command flakes"
@@ -126,11 +147,12 @@ jobs:
steps:
- uses: actions/checkout@v4
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout.
# (node:22-bookworm is the full image and already has all three — this is belt-and-braces
# against a future slim-image swap, and costs one cached apt call.)
# The Determinate installer needs curl + xz; git so nix can read the flake from the checkout;
# rsync + ssh to ship the built cache to unom-1. (node:22-bookworm is the full image and
# already has all but rsync — this is belt-and-braces against a future slim-image swap, and
# costs one cached apt call.)
- name: Installer prerequisites
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates curl xz-utils git rsync openssh-client
# `--init none` is the container mode: no systemd, no daemon. Running as root, nix then talks
# to the store directly. Determinate Nix is also what the Nix box (.21) runs, so CI and the
@@ -151,7 +173,19 @@ jobs:
# with "no space left on device" mid-`bun install`), and a Nix build is the heaviest thing
# here — so record the headroom, or a future failure is a guess.
- name: Environment
run: df -h / /nix /tmp || true
# Disk AND memory. This job's recurring failure is an OOM kill, and `df` cannot explain
# one — a run that dies at exit 137 with only disk numbers in the log is a guess.
run: |
df -h / /nix /tmp || true
free -h 2>/dev/null || grep -E '^(MemTotal|MemAvailable|SwapTotal)' /proc/meminfo || true
nproc 2>/dev/null || true
# THE number for this job's recurring exit 137. `free` and /proc/meminfo report the HOST
# inside a container, so they showed 125Gi total / 48Gi available on a run that then got
# bun SIGKILLed (19444) — a cgroup cap is invisible to them and is the only remaining
# explanation. cgroup v2 first, then v1; "max" means uncapped.
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|| echo "no cgroup memory limit readable"
# Evaluates + instantiates every flake output without building any of it.
- name: nix flake check (eval only)
@@ -183,10 +217,144 @@ jobs:
|| { echo "installed console is not a bun bundle" >&2; exit 1; }
echo "bun packages OK: $web $scripting"
# ── binary cache (push to main only) ───────────────────────────────────────────────────────
#
# Decided against a bucket on storage.unom.io even though sccache already uses it and the
# credentials already exist: it is local RustFS on the home uplink with no CDN in front, so
# every NixOS user's download would come off the same pipe every CI runner uses — and S3
# answers 403, not 404, for a missing key, which nix treats as a hard error rather than a
# cache miss (see packaging/nix/server/Caddyfile). unom-1 already serves the flatpak repo
# this way from a cloud IP; a Nix cache is the same static-files-behind-caddy shape.
#
# Gitea itself cannot host this at all: it has 23 package registry types and none is Nix, and
# the binary cache protocol wants fixed anonymous paths at a URL root (/nix-cache-info,
# /<hash>.narinfo, /nar/<hash>.nar.xz) that /api/packages/{owner}/generic/… cannot express.
- name: Cache publish preflight
id: cachecfg
if: ${{ github.event_name == 'push' }}
env:
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
# Guard BEFORE the build, not before the upload: an unconfigured cache must not cost an
# hour of rustc first. No-ops cleanly until the secret exists, exactly as flatpak.yml's
# repo deploy does, so this workflow stays green through setup.
run: |
set -eu
if [ -n "${NIX_CACHE_SIGNING_KEY:-}" ] && [ -n "${DEPLOY_HOST:-}" ]; then
echo "go=true" >> "$GITHUB_OUTPUT"
else
echo "go=false" >> "$GITHUB_OUTPUT"
echo "::warning::NIX_CACHE_SIGNING_KEY/DEPLOY_HOST not set — skipping the binary cache publish (see packaging/nix/README.md)."
fi
- name: Build the publishable packages
if: ${{ steps.cachecfg.outputs.go == 'true' }}
# Everything a user can install. punktfunk-gamescope earns its place here more than any
# other: host.gamescopeHdr DEFAULTS TRUE, so without it in the cache every
# `services.punktfunk.host.enable = true` still compiles a compositor from source.
run: |
"$NIX" build --print-build-logs \
.#punktfunk-host .#punktfunk-client .#punktfunk-tray \
.#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope
# This is now the heaviest job on the fleet — a full workspace build plus gamescope fills
# the store with tens of GB, and this fleet ran a runner out of disk on 2026-08-06. Record
# the headroom AFTER the build too, or a future "no space left on device" is a guess.
df -h / /nix /tmp || true
- name: Sign + publish to nix.unom.io
if: ${{ steps.cachecfg.outputs.go == 'true' }}
env:
NIX_CACHE_SIGNING_KEY: ${{ secrets.NIX_CACHE_SIGNING_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
# `set -eu`, NOT `set -euo pipefail`: act_runner may execute a step's `run:` under dash in
# these containers (see scripts/ci/ensure-sccache.sh), and dash dies on `-o pipefail` with
# "Illegal option". The two places below where a pipeline's LEFT side must be able to fail
# the step are written as redirects instead, so nothing depends on pipefail.
set -eu
PKGS=".#punktfunk-host .#punktfunk-client .#punktfunk-tray .#punktfunk-web .#punktfunk-scripting .#punktfunk-gamescope"
# 1) Pick what to publish. PUBLISH ONLY OUR OWN PATHS — this is the difference between
# ~300 MB and several GB per run, and it is not a corner cut: a runtime closure here
# is our binaries plus stock nixpkgs (ffmpeg, gtk4, glibc, …), and every stock path is
# already on cache.nixos.org, served by a real CDN. Mirroring them onto unom-1 would
# cost disk and home-to-cloud bandwidth to serve a WORSE copy of what users already
# have. Nothing in nixpkgs is named punktfunk, so the name filter is exact.
paths="$("$NIX" path-info -r $PKGS | grep -- '-punktfunk' || true)"
[ -n "$paths" ] || { echo "::error::no punktfunk store paths in the closure — the name filter is broken"; exit 1; }
echo "$paths"
# The filter is a string match, so it would fail SILENTLY if a pname ever changed — and
# the package most likely to drift is gamescope, the most expensive one to lose. Assert
# every built output is actually covered rather than discovering it as a user rebuild.
for out in $("$NIX" build --print-out-paths $PKGS); do
printf '%s\n' "$paths" | grep -qxF "$out" \
|| { echo "::error::$out is not matched by the '-punktfunk' filter — publish would silently omit it"; exit 1; }
done
# 2) Sign into a local binary cache. The secret is the whole `name:base64` line from
# `nix key generate-secret`; the matching public key is what users pin (README).
KEYDIR="$(mktemp -d)"; chmod 700 "$KEYDIR"
printf '%s' "$NIX_CACHE_SIGNING_KEY" > "$KEYDIR/key"; chmod 600 "$KEYDIR/key"
printf '%s\n' "$paths" | xargs "$NIX" copy --to "file://$PWD/nix-cache?secret-key=$KEYDIR/key"
# Publish the PUBLIC half beside the cache and echo it here. Users must pin this key, so
# it needs to be fetchable from the cache itself rather than only from a doc that can
# drift — and on the first run this log line is where the value for README.md comes from.
# Redirect, not `| tee`: without pipefail a failing nix would be masked by tee's success
# and publish an EMPTY public key, which every user would then pin.
"$NIX" key convert-secret-to-public < "$KEYDIR/key" > nix-cache/punktfunk-cache.pub
cat nix-cache/punktfunk-cache.pub
rm -rf "$KEYDIR"
echo "publishing $(find nix-cache -name '*.narinfo' | wc -l) paths, $(du -sh nix-cache | cut -f1)"
# 3) Ship it. Same deploy key and retry discipline as flatpak.yml — this runner's link to
# unom-1 drops TCP dials under load.
install -d -m700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy; chmod 600 ~/.ssh/deploy
SSH="ssh -i $HOME/.ssh/deploy -p ${DEPLOY_PORT:-22} -o StrictHostKeyChecking=accept-new"
DEST="${DEPLOY_USER}@${DEPLOY_HOST}"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "mkdir -p ~/$DEPLOY_DIR/site/nar"
# ⚠ ORDER IS LOAD-BEARING: NARs first, narinfos second. A narinfo whose NAR has not landed
# yet is a HARD download failure for whoever fetches it in that window; a NAR nothing
# points at yet is simply invisible. rsync renames each file into place atomically, so a
# cancelled run (this workflow has cancel-in-progress) can only ever under-publish.
# No --delete: superseded paths are aged out by prune.sh below instead, so a client
# mid-download is never pulled out from under.
bash scripts/ci/retry.sh 5 rsync -az --info=stats1 -e "$SSH" nix-cache/nar/ "$DEST:$DEPLOY_DIR/site/nar/"
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" nix-cache/nix-cache-info nix-cache/punktfunk-cache.pub nix-cache/*.narinfo "$DEST:$DEPLOY_DIR/site/"
bash scripts/ci/retry.sh 5 rsync -az -e "$SSH" \
packaging/nix/server/compose.production.yml packaging/nix/server/Caddyfile packaging/nix/server/prune.sh \
"$DEST:$DEPLOY_DIR/"
bash scripts/ci/retry.sh 5 $SSH "$DEST" "cd ~/$DEPLOY_DIR && docker compose -f compose.production.yml up -d"
# 4) Bound it. The flatpak repo next door reached 3.84 GB publishing this same way with
# no sweep, on a box that has run out of disk before; this one gets the sweep from the
# first publish. Never allowed to fail the job — the cache is already live by now, and
# a growing disk is a slower problem than a red main.
bash scripts/ci/retry.sh 3 $SSH "$DEST" "sh ~/$DEPLOY_DIR/prune.sh ~/$DEPLOY_DIR/site 180" \
|| echo "::warning::cache prune failed — published cache may be growing unbounded"
# 5) Prove the published cache actually answers, rather than assuming the rsync landed.
# A substituter that 200s on nix-cache-info but 403s on a miss is the failure mode that
# breaks users' builds, so check both.
bash scripts/ci/retry.sh 5 curl -fsS "$CACHE_URL/nix-cache-info"
miss="$(curl -sS -o /dev/null -w '%{http_code}' "$CACHE_URL/0000000000000000000000000000000000.narinfo")"
[ "$miss" = 404 ] || { echo "::error::cache returns $miss for an absent path; nix needs 404 or every user build fails"; exit 1; }
echo "published → $CACHE_URL"
# Opt-in only: the full Rust workspace through crane, which is the hour-long leg.
# `github.event.inputs.*` (string) rather than `inputs.*` — the portable spelling.
# Accept BOTH shapes. A checkbox dispatched from the Gitea UI arrives as the STRING
# "true", but an API dispatch (scripts, cross-repo automation) can deliver a real JSON
# boolean, and `== 'true'` silently misses it — the step is skipped, the run goes green,
# and the log looks identical to a run that genuinely had nothing to do. MEASURED
# 2026-08-19: dispatched with build-gamescope while verifying a flake.lock bump, and this
# step skipped while the job reported success — a green that proved nothing about the
# very package being fixed. Still no `inputs.*`: that context is the thing Gitea's parser
# is least reliable about, which is why this file used github.event.inputs to begin with.
- name: Build the Rust packages (dispatch opt-in)
if: ${{ github.event.inputs.build-rust == 'true' }}
if: ${{ github.event.inputs.build-rust == 'true' || github.event.inputs.build-rust == true }}
run: |
"$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client
@@ -196,6 +364,6 @@ jobs:
# longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if
# something actually builds it.
- name: Build the patched gamescope (dispatch opt-in)
if: ${{ github.event.inputs.build-gamescope == 'true' }}
if: ${{ github.event.inputs.build-gamescope == 'true' || github.event.inputs.build-gamescope == true }}
run: |
"$NIX" build --print-build-logs .#punktfunk-gamescope
+5 -2
View File
@@ -37,15 +37,18 @@ jobs:
- uses: actions/checkout@v4
# Both installs retried: bun's download-and-extract is single-shot, and a truncated tarball
# reads as `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job
# is the worst place to lose to a dropped packet — the tag is already pushed.
- name: Build the SDK (file:../sdk dependency source)
working-directory: sdk
run: |
bun install --frozen-lockfile --ignore-scripts
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
- name: Install dependencies
working-directory: plugin-kit
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
# bun 1.3 installs a `file:` dependency by copying its DIRECTORIES but symlinking each
# top-level FILE to itself — `node_modules/@punktfunk/host/package.json -> package.json`, a
+13 -1
View File
@@ -35,6 +35,7 @@ on:
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- 'scripts/alsa-ucm2/**'
- '.gitea/workflows/rpm.yml'
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the `*-canary` rpm
# groups, tags to the base groups (`bazzite`/`fedora-44`) — separate repos, so the old
@@ -175,7 +176,9 @@ jobs:
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
bun run build
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
@@ -221,6 +224,15 @@ jobs:
# never the board"; this is that. Host must carry NOTHING; the worker must carry exactly
# cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to
# fail takes the job down instead of waving the release through.
- name: The DualSense UCM drop-in must still bite
# scripts/alsa-ucm2/ hooks into alsa-ucm-conf's own dispatcher, so an upstream rename or
# reorder can neuter it with no error anywhere — and what comes back is the Spider-Man
# EXCEPTION_ACCESS_VIOLATION, not a quieter pad. This is the only leg that runs on a real
# Fedora tree, hence the two packages. Skips itself on any box without them.
run: |
dnf -y install alsa-ucm alsa-ucm-utils
sh scripts/ci/check-dualsense-ucm.sh
- name: Assert the capability matrix (rpm)
run: |
bash scripts/ci/assert-cap-matrix.sh --self-test
+4 -1
View File
@@ -39,8 +39,11 @@ jobs:
- uses: actions/checkout@v4
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement). A publish job is the
# worst place to lose to a dropped packet — the tag is already pushed.
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Typecheck
run: bun run typecheck
+3 -1
View File
@@ -40,8 +40,10 @@ jobs:
- uses: actions/checkout@v4
# --ignore-scripts skips the prepare→codegen hook (mirrors ci.yml); run codegen
# explicitly since build-storybook has no prebuild hook of its own.
# Retried: bun's download-and-extract is single-shot, and a truncated tarball reads as
# `Fail extracting tarball` (ci.yml's web job has the measurement).
- name: Install dependencies
run: bun install --frozen-lockfile --ignore-scripts
run: bash ../scripts/ci/retry.sh 3 bun install --frozen-lockfile --ignore-scripts
- name: Generate API client + i18n messages
run: bun run codegen
# Pulls the matching Chromium build + the apt libs it needs (root in-container).
+38 -8
View File
@@ -56,8 +56,12 @@
#
# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ──────────────────────
#
# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes
# can install a real package (Start tile, clean install/uninstall) instead of a loose exe.
# Publishes THREE artifacts per arch (x64 + ARM64) to Gitea's generic package registry, all packed
# from one assembled layout:
# punktfunk-client-setup_<arch>.exe — Inno Setup per-user installer, the DEFAULT download
# (stable path Steam can launch: overlay + Big Picture work)
# punktfunk-client-windows_<arch>-portable.zip — the same file set, no installer
# punktfunk-client-windows_<arch>.msix — kept for Microsoft Store compatibility
#
# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group)
# Packaging internals: clients/windows/packaging/README.md.
@@ -283,6 +287,28 @@ jobs:
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix
# The DEFAULT download: a per-user Inno Setup exe + a portable zip, packed from the layout
# the MSIX step just assembled. The MSIX shape (WindowsApps ACLs, alias-only activation)
# breaks Steam's non-Steam-game picker, the Steam overlay injection and Big Picture launch;
# the installer's stable %LOCALAPPDATA%\Programs\Punktfunk path is the fix. The MSIX stays
# published for Microsoft Store compatibility. Same signing env as the MSIX step above.
- name: Pack + sign installer + portable zip
if: github.event_name != 'pull_request'
shell: pwsh
env:
AZURE_CODESIGNING_ENDPOINT: https://neu.codesigning.azure.net/
AZURE_CODESIGNING_ACCOUNT: unomsigning
AZURE_CODESIGNING_PROFILE: unom-io
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }}
MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }}
run: |
& clients/windows/packaging/pack-client-installer.ps1 `
-Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} `
-LayoutDir ${{ matrix.td }}\msix\layout -OutDir ${{ matrix.td }}\installer
- name: Publish to Gitea generic registry
if: github.event_name != 'pull_request'
shell: pwsh
@@ -301,7 +327,10 @@ jobs:
# on that accident, so removing the quotes can't silently reintroduce it.
$aliasNames = @{ "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" }
if ($env:MSIX_CER_PATH) { $aliasNames[$env:MSIX_CER_PATH] = "$($env:PKG)_${{ matrix.arch }}.cer" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) }
# The installer + portable zip (the default download; docs point at these alias URLs).
if ($env:CLIENT_SETUP_PATH) { $aliasNames[$env:CLIENT_SETUP_PATH] = "punktfunk-client-setup_${{ matrix.arch }}.exe" }
if ($env:CLIENT_ZIP_PATH) { $aliasNames[$env:CLIENT_ZIP_PATH] = "$($env:PKG)_${{ matrix.arch }}-portable.zip" }
$files = @($env:MSIX_PATH, $env:MSIX_CER_PATH, $env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH) | Where-Object { $_ -and (Test-Path $_) }
if (-not $files) { throw "pack produced no artifacts to publish" }
function Put($f, $url) {
# The generic registry makes a versioned path immutable and 409s a re-upload, so a tag
@@ -324,10 +353,11 @@ jobs:
Put $f "$base/$alias/$an"
}
# On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both
# arch legs attach to the same release concurrently — the helper's create-or-fetch handles
# the race, and x64/arm64 filenames differ so the assets don't collide.
- name: Attach MSIX to the Gitea release (stable tags only)
# On a real release, also attach the installer + portable zip + MSIX (+ its .cer) to the
# unified Gitea Release. Both arch legs attach to the same release concurrently — the
# helper's create-or-fetch handles the race, and x64/arm64 filenames differ so the assets
# don't collide.
- name: Attach client artifacts to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
shell: pwsh
env:
@@ -335,6 +365,6 @@ jobs:
run: |
. scripts/ci/gitea-release.ps1
$rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto'
foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) {
foreach ($f in @($env:CLIENT_SETUP_PATH, $env:CLIENT_ZIP_PATH, $env:MSIX_PATH, $env:MSIX_CER_PATH)) {
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
}
+22
View File
@@ -0,0 +1,22 @@
# AGENTS.md
Guidance for coding agents working in this repository.
## Agent skills
### Issue tracker
Issues live as Gitea issues in `unom/punktfunk` on `git.unom.io`, driven by the `gitea` MCP server
(`gh`/`glab`/`tea` do not work here), and every write needs the user's go-ahead first.
See `docs/agents/issue-tracker.md`.
### Triage labels
The five canonical roles, each label string equal to its name — `needs-triage`, `needs-info`,
`ready-for-agent`, `ready-for-human`, `wontfix` — none of which exist in the tracker yet.
See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: one `CONTEXT.md` and one `docs/adr/` at the repo root, covering the whole
workspace. See `docs/agents/domain.md`.
+1935
View File
File diff suppressed because it is too large Load Diff
+29 -3
View File
@@ -83,15 +83,41 @@ Two more gates that only apply to some changes:
instead of waiting for the CI job that compiles it.
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
and copy the snapshot the docs site serves:
and CI fails if the committed copy drifts. `api/openapi.json` is gated the same way: the `rust` job
regenerates the spec and diffs it against the committed file, and the `docs-drift` job checks that
`docs-site/public/openapi.json` the snapshot the docs site serves — is a byte-for-byte copy of it.
Touch the management API and CI stays red until you regenerate and re-copy:
```sh
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
## Where facts live (docs vs READMEs vs website)
Every user-facing fact has exactly one canonical home; everything else links to it. Duplicated
walkthroughs are how the docs drifted before — don't add new ones.
| Surface | Owns | Never contains |
|---|---|---|
| [docs-site](https://docs.punktfunk.unom.io) (`docs-site/content/`) | All user-facing facts: install, config, features, troubleshooting | Design rationale |
| READMEs (root, `packaging/*`, `scripts/*`) | Dev/packager rationale and pointers into the docs | User walkthroughs duplicated from docs-site |
| [punktfunk.unom.io](https://punktfunk.unom.io) (separate repo) | Marketing, downloads, blog | Instructions — it deep-links the docs instead |
| punktfunk-planning (private) | Design rationale, RFCs, plans | Anything user-facing |
Docs pages are written for one of two audiences, not both at once: the **get-started track**
(quickstart, install, pairing — short, one task per page, happy path only) assumes no Linux
expertise; the **reference track** (configuration, CLI, API, per-compositor pages) is allowed to be
dense. When a change touches a user-facing fact, update the docs-site page that owns it in the same
PR.
CI enforces the cheap half of this (`scripts/ci/check-docs-drift.sh` and `check-docs-links.sh`):
the OpenAPI snapshot must match `api/openapi.json`, the docs-site copy of `data/platforms.json` must
match the canonical one, `scripts/install.sh` must carry the file's install lines verbatim, every `PUNKTFUNK_*` variable the docs mention
must still exist in the tree, the counts of undocumented `PUNKTFUNK_*` variables and undocumented
`punktfunk-host` subcommands may never grow (document the new knob, or consciously raise the
baseline in the script), and internal docs links must resolve.
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
Generated
+48 -36
View File
@@ -1090,7 +1090,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"pf-capture",
@@ -1222,7 +1222,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"pf-win-display",
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2343,7 +2343,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.29.0"
version = "0.31.2"
[[package]]
name = "lazy_static"
@@ -2446,7 +2446,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"bindgen",
"cmake",
@@ -2475,7 +2475,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"punktfunk-core",
]
@@ -2967,7 +2967,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-bitstream"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"cros-codecs",
"tracing",
@@ -2975,7 +2975,7 @@ dependencies = [
[[package]]
name = "pf-capture"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -2996,7 +2996,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3027,11 +3027,12 @@ dependencies = [
"windows 0.62.2 (git+https://github.com/microsoft/windows-rs?rev=acb5a1a7441033d9312b16842af02eb0c2b403dc)",
"winreg",
"x11rb",
"zbus",
]
[[package]]
name = "pf-clipboard"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3049,7 +3050,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3057,6 +3058,7 @@ dependencies = [
"pf-presenter",
"punktfunk-core",
"sdl3",
"serde",
"serde_json",
"skia-safe",
"tracing",
@@ -3071,7 +3073,7 @@ dependencies = [
[[package]]
name = "pf-dxvadec"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3081,7 +3083,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3107,7 +3109,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"libc",
@@ -3120,7 +3122,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"pf-host-config",
@@ -3134,11 +3136,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.29.0"
version = "0.31.2"
[[package]]
name = "pf-inject"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3167,14 +3169,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3189,7 +3191,7 @@ dependencies = [
[[package]]
name = "pf-update"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"serde",
"serde_json",
@@ -3197,7 +3199,7 @@ dependencies = [
[[package]]
name = "pf-update-check"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"aws-lc-rs",
@@ -3209,7 +3211,7 @@ dependencies = [
[[package]]
name = "pf-vaadec"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"cros-codecs",
"pf-bitstream",
@@ -3218,7 +3220,7 @@ dependencies = [
[[package]]
name = "pf-vdisplay"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ashpd",
@@ -3251,7 +3253,7 @@ dependencies = [
[[package]]
name = "pf-vkdecode"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"ash",
"cros-codecs",
@@ -3262,7 +3264,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"pf-paths",
"punktfunk-core",
@@ -3273,7 +3275,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ash",
@@ -3485,7 +3487,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3495,9 +3497,10 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"android_logger",
"anyhow",
"jni",
"libc",
"log",
@@ -3505,7 +3508,12 @@ dependencies = [
"ndk",
"ndk-sys",
"opus",
"pf-client-core",
"pf-console-ui",
"punktfunk-core",
"serde",
"serde_json",
"skia-safe",
"tracing",
"uac-host",
"usbfs-iso",
@@ -3513,7 +3521,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"async-channel",
@@ -3530,21 +3538,23 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"log",
"pf-client-core",
"pf-console-ui",
"pf-presenter",
"punktfunk-core",
"serde_json",
"tracing",
"tracing-log",
"tracing-subscriber",
"winresource",
]
[[package]]
name = "punktfunk-client-windows"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"async-channel",
"mdns-sd",
@@ -3562,7 +3572,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"aes-gcm",
"cbindgen",
@@ -3572,6 +3582,7 @@ dependencies = [
"hmac 0.13.0",
"if-addrs",
"libc",
"log",
"opus",
"proptest",
"quinn",
@@ -3594,7 +3605,7 @@ dependencies = [
[[package]]
name = "punktfunk-encode-worker"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"pf-encode",
"tracing",
@@ -3603,7 +3614,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"aes",
"aes-gcm",
@@ -3673,7 +3684,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3687,11 +3698,12 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"anyhow",
"ksni",
"libc",
"pf-paths",
"punktfunk-core",
"rustls",
"serde",
@@ -3710,7 +3722,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.29.0"
version = "0.31.2"
dependencies = [
"bindgen",
"cmake",
+1 -1
View File
@@ -65,7 +65,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.29.0"
version = "0.31.2"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
+4 -29
View File
@@ -109,36 +109,11 @@ installer (all-vendor: NVIDIA, AMD, Intel).
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
installed first:
```sh
mkdir -p ~/.config/punktfunk
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
# (on Bazzite take host.env.bazzite instead)
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
systemctl --user enable --now punktfunk-host # the streaming host
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
```
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
line before the replacement — the install guide has the snippet). Then open
`https://<host-ip>:47992` and pair.
How the virtual display and input are wired up depends on your desktop — see
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
The per-platform guide walks you through the rest — first run, the web console, pairing, and the
desktop-specific wiring ([KDE](https://docs.punktfunk.unom.io/docs/kde) ·
[GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
[Sway](https://docs.punktfunk.unom.io/docs/sway).
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
nothing to run by hand — open the web console and pair. Use
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
`setup.exe` over the old install; uninstall from Add/Remove Programs.
[Sway](https://docs.punktfunk.unom.io/docs/sway)).
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
+141 -4
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.29.0"
"version": "0.31.2"
},
"paths": {
"/api/v1/client-logs": {
@@ -1860,6 +1860,69 @@
}
}
},
"/api/v1/library/provider/{provider}/running": {
"put": {
"tags": [
"library"
],
"summary": "Report which of a provider's titles are running",
"description": "The **live** counterpart to the `detect` hints in a reconcile payload: that one says *how to\nrecognize* a title's process, this one says *it is running now* (design §9,\n[`crate::runstate`]). For a provider that starts games itself and knows when they stop —\nPlaynite tracks every launch and fires an event on both edges — this is a fact the host would\notherwise have to re-derive by scanning, and for a title with nothing to scan for (an emulated\ngame, a manually added one) could not derive at all.\n\nDeclarative and idempotent, like the reconcile: the body is the provider's **complete** running\nset, so a missed event, a plugin restart or an install mid-game all self-correct on the next\nreport rather than drifting.\n\nThe report **expires** after `ttl_s` (90s) unless restated, which is what makes it safe for a\nlive provider to keep a streaming session open for a game the host cannot see: a plugin that\ndies with a game running stops counting shortly after, and the host falls back to process\nscanning exactly as it does without one. Re-report on every change **and** on a timer well\ninside the window.\n\nTitles the provider does not currently publish are ignored (counted in `unknown`), not an error:\na report may legitimately race its own reconcile.",
"operationId": "reportProviderRunning",
"parameters": [
{
"name": "provider",
"in": "path",
"description": "The provider id ([a-z0-9._-], `manual` reserved)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningInput"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The report was accepted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderRunningAccepted"
}
}
}
},
"400": {
"description": "Invalid provider id or payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/library/scanners": {
"get": {
"tags": [
@@ -4242,7 +4305,7 @@
},
"state": {
"type": "string",
"description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).",
"description": "`launching` (launched, not seen running yet), `running`, `exited`, `untracked` (this title\nexposes nothing the host can recognize its process by, so its exit will never be noticed),\nor `grace` (its session is gone and it will be ended when the reconnect window closes).",
"example": "running"
},
"store": {
@@ -6194,6 +6257,14 @@
}
}
},
"GameOnNewLaunch": {
"type": "string",
"description": "What to do with a title this client already has running when it launches a **different** one.\n\nThe third axis rather than a fourth value on [`GameOnSessionEnd`], because it answers a different\nquestion at a different moment: that one is \"this session is over, what about its game\", this one\nis \"the player asked for something else, what about the last thing\". Folding them together would\ntie two unrelated choices to one switch — an operator who wants a game to survive a disconnect\nvery plausibly still wants it closed when they pick another title.\n\nScoped to the **same client's own launches**, and only ever to launches this host performed\nitself ([`crate::launchreg`]). A game the player started at the machine was never recorded there,\nso it can never be closed by this; nor can another client's game, which would otherwise let one\ndevice end someone else's session mid-play.",
"enum": [
"keep",
"end"
]
},
"GameOnSessionEnd": {
"type": "string",
"description": "What to do with the launched game when its session ends.",
@@ -6617,7 +6688,7 @@
},
"HostInfo": {
"type": "object",
"description": "Host identity and advertised capabilities (static for the life of the process).",
"description": "Host identity and advertised capabilities (static for the life of the process, except\n`local_ip`).",
"required": [
"hostname",
"uniqueid",
@@ -6663,7 +6734,7 @@
},
"local_ip": {
"type": "string",
"description": "Best-effort primary LAN IP."
"description": "Best-effort primary LAN IP, read fresh on every request — a host that started before its\nnetwork did (cold boot) reports `127.0.0.1` only until it actually has an address, and a\nhost that moves networks reports the new one. Poll it rather than caching it."
},
"os": {
"type": "string",
@@ -7784,6 +7855,46 @@
}
}
},
"ProviderRunningAccepted": {
"type": "object",
"description": "The result of a liveness report.",
"required": [
"matched",
"unknown",
"ttl_s"
],
"properties": {
"matched": {
"type": "integer",
"description": "How many reported titles matched an entry this provider currently publishes.",
"minimum": 0
},
"ttl_s": {
"type": "integer",
"format": "int64",
"description": "Seconds this report stays authoritative without being restated — re-report inside it while\nanything is running.",
"minimum": 0
},
"unknown": {
"type": "integer",
"description": "How many were ignored because no such entry exists (a report that raced a reconcile).",
"minimum": 0
}
}
},
"ProviderRunningInput": {
"type": "object",
"description": "Request body for `reportProviderRunning`.",
"properties": {
"running": {
"type": "array",
"items": {
"$ref": "#/components/schemas/RunningTitle"
},
"description": "Every title of this provider's that is running **right now**. The full set, not a delta:\nanything absent from it is reported as stopped."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
@@ -7838,6 +7949,28 @@
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
"required": [
"external_id"
],
"properties": {
"external_id": {
"type": "string",
"description": "The provider's own stable id for the title — the same key its reconcile payload uses."
},
"pid": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The process id the provider started for it, when it knows one. Optional, and never trusted\nas a bare number: the host re-resolves it and pins it to its start time before it is ever\nsignalled, so a stale or recycled pid simply contributes nothing.",
"minimum": 0
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -8095,6 +8228,10 @@
"description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.",
"minimum": 0
},
"game_on_new_launch": {
"$ref": "#/components/schemas/GameOnNewLaunch",
"description": "End this client's previous game when it launches a different one. See [`GameOnNewLaunch`]."
},
"game_on_session_end": {
"$ref": "#/components/schemas/GameOnSessionEnd",
"description": "End the launched game when the session ends. See [`GameOnSessionEnd`]."
+92
View File
@@ -0,0 +1,92 @@
# Flatpak CI builder: everything flatpak.yml used to install per run, plus the Flathub
# runtime set the manifest declares. Content-keyed and rebuilt only when the ci/ tree
# changes (docker.yml `builders`).
#
# docker build -f ci/flatpak-ci.Dockerfile -t punktfunk-flatpak-ci ci
#
# MEASURED on run 18855 (2026-08-17), a green 21m23s build, this image removes:
# 63 s `dnf -y install nodejs` (act_runner execs JS actions with the CONTAINER's node)
# 240 s `Tooling` — 330 packages
# 168 s restoring a 1.5 GB actions/cache of ~/.local/share/flatpak
# i.e. ~7.8 min of an 8 min preamble in front of a 6 min compile. The runtimes are the
# bulk of the image and the reason it exists; a package-only image would leave the
# biggest single step in place.
#
# WHY FEDORA and not the flathub org.flatpak.Builder image: flatpak.yml's job container
# must run bubblewrap under --privileged in the act_runner Docker executor, and Fedora
# ships a flatpak/flatpak-builder pair recent enough for the manifest with the kernel
# userns support already enabled. See flatpak.yml's header for the --privileged and
# --network host constraints, both of which still apply to this image.
FROM docker.io/library/fedora:43
# nss-resolve trap, baked. fedora:43's nsswitch.conf is
# `hosts: files myhostname resolve [!UNAVAIL=return] dns`: `resolve` is nss-resolve
# (systemd-resolved), which does not run in a CI container. glibc consumers (git, curl,
# dnf) fall through to `dns`; flatpak/ostree's resolver does NOT — the absent-daemon
# socket connect trips `[!UNAVAIL=return]` and it reports "[6] Could not resolve
# hostname". flatpak.yml carries the same sed as a runtime step and had to repeat it
# after its dnf transaction, because a systemd upgrade's authselect trigger regenerates
# the file. Doing it here, after the last dnf in the image, ends that whack-a-mole for
# every consumer — the workflow's copy stays only as a guard for a lagging :latest.
RUN dnf -y install \
# the build itself
flatpak flatpak-builder \
# ostree CLI: the seed step pulls the published channels' tip commits with it
# (`dnf install flatpak` brings the LIBRARY, not the binary)
ostree \
# actions/checkout is a JS action and act_runner does not inject a node
nodejs git git-lfs \
# flatpak-cargo-generator.py needs aiohttp + tomlkit (NOT the old `toml`)
python3 python3-aiohttp python3-tomlkit \
# sign the OSTree repo + rsync it to unom-1; jq/curl for the registry publish
gnupg2 rsync openssh-clients curl jq \
&& dnf clean all \
&& sed -i 's/resolve \[!UNAVAIL=return\] //' /etc/nsswitch.conf \
&& ! grep -q 'resolve \[!UNAVAIL=return\]' /etc/nsswitch.conf
# The Flathub runtime/SDK set, baked into the image's USER installation (/root/.local,
# HOME=/root in the job container) — the same path flatpak.yml used to restore from
# actions/cache, so `flatpak-builder --user` finds these with no further wiring.
#
# ⚠ KEEP IN SYNC with packaging/flatpak/io.unom.Punktfunk.yml: GNOME_VERSION is the
# manifest's `runtime-version`, FREEDESKTOP_VERSION is what flatpak-builder resolves the
# two `sdk-extensions` to (it prints them as "Dependency Extension: … 25.08"). A drift
# here is not fatal — flatpak.yml's prefetch step downloads whatever is missing from
# Flathub, retried — but it silently costs ~1.5 GB and several minutes per run, which is
# precisely what this image exists to avoid. Bump both in the same commit as the manifest.
#
# `flatpak install` HERE, at docker-build time, needs no privileges — VERIFIED 2026-08-17
# in a plain `docker run` container where bwrap was proven broken first
# ("bwrap: No permissions to creating new namespace"): the install still exited 0 and
# `flatpak info --user` resolved the ref. flatpak's post-deploy triggers are the only
# part that wants bwrap and they are best-effort, so this layer does NOT need the
# --privileged that the CONSUMING job needs for flatpak-builder's actual sandbox.
#
# Related refs (org.freedesktop.Platform.GL.default{,-extra}, codecs-extra,
# org.gnome.Platform.Locale) come along by default and are deliberately kept: they are
# what `flatpak-builder --install-deps-only` would otherwise pull on every run, so a
# --no-related image would look smaller and cost more. org.gnome.Platform//50 alone
# unpacks to 2.4 GB; the whole set is the same content the 1.5 GB (compressed) runtime
# actions/cache already carried — this moves it from the cache server to the registry,
# where Docker keeps it on the runner's disk instead of re-extracting it every run.
ARG GNOME_VERSION=50
ARG FREEDESKTOP_VERSION=25.08
RUN flatpak remote-add --user --if-not-exists flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo \
&& flatpak install --user -y --noninteractive flathub \
"org.gnome.Platform//${GNOME_VERSION}" \
"org.gnome.Sdk//${GNOME_VERSION}" \
"org.freedesktop.Sdk.Extension.rust-stable//${FREEDESKTOP_VERSION}" \
"org.freedesktop.Sdk.Extension.llvm20//${FREEDESKTOP_VERSION}" \
# Assert rather than trust. `flatpak install` treats its post-deploy triggers as
# best-effort and this RUN's exit status would not notice a ref that failed to
# deploy; an image that merely LOOKS warm would push the 1.5 GB back onto every
# single run, where it reads as "flatpak is slow again" instead of a broken image.
# Fail the image build loudly instead — docker.yml goes red and :latest never moves.
&& for ref in \
"org.gnome.Platform//${GNOME_VERSION}" \
"org.gnome.Sdk//${GNOME_VERSION}" \
"org.freedesktop.Sdk.Extension.rust-stable//${FREEDESKTOP_VERSION}" \
"org.freedesktop.Sdk.Extension.llvm20//${FREEDESKTOP_VERSION}" \
; do flatpak info --user "$ref" >/dev/null || exit 1; done \
&& flatpak list --user --columns=ref
+2
View File
@@ -1,6 +1,8 @@
# Gradle / Android build artifacts
.gradle/
build/
# Kotlin compiler daemon session files, written by any Gradle invocation.
.kotlin/
local.properties
*.iml
.idea/
@@ -19,38 +19,29 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import io.unom.punktfunk.models.PendingTrust
// The prompts that say the SAME thing in both interfaces.
// The touch UI's prompts, each described once — a title, a list of [DialogAction]s (primary
// first) and a body — and drawn as a Material `AlertDialog`.
//
// Every one of these existed twice — a Material `AlertDialog` in ConnectDialogs.kt and a console
// glass card in GamepadDialogs.kt — with the two copies maintained by hand. Predictably they
// drifted, and always in the direction of the console losing something: "Pair with PIN…" lost its
// ellipsis, "if no prompt appears when you tap Allow" became "after Allow", and the speed test
// stopped telling console users which layer Apply would write to at all.
//
// What is shared here is the DESCRIPTION of a prompt — a title, a list of [DialogAction]s and a
// body — and what stays per-interface is only how that description is drawn. That split is the
// whole point: a copy change now lands in both places because there is only one place.
//
// ⚠ Deliberately NOT unified, and they belong apart: the PIN ceremony (a numeric keyboard field
// and an editable device name on touch; four D-pad digit slots on the console — different input
// models, not different skins), Add/Edit Host (a bottom sheet and a full screen with its own
// on-screen keyboard), and the host action list (an anchored dropdown vs a modal stack, and the
// touch one grows a row per profile).
// History: these used to be drawn a second way, as the Compose console's glass card, and the two
// renderers drifted by hand until the descriptions were shared here. The Compose console is gone
// (the console is the Skia shell now — design/android-skia-console-port.md — and it draws its own
// pairing/trust screens), so only the touch renderer remains; the shared-description shape stays
// because it is the right shape regardless.
/** One button of a prompt. [primary] lifts it into the confirm slot; the rest lay out beside. */
class DialogAction(
val label: String,
val primary: Boolean = false,
val enabled: Boolean = true,
val onClick: () -> Unit,
)
/**
* One prompt, drawn as whichever interface is running.
*
* [actions] is ordered PRIMARY FIRST the console stacks them in that order with the cursor on
* the first, and the touch renderer lifts that same first action into `confirmButton` and lays the
* rest out beside it. One order, two idioms, no per-dialog bookkeeping.
*
* The two renderers cannot be one tree: an `AlertDialog` composes into its own platform window
* while [ConsoleModal] is a plain Box in the calling tree which is also why the console one
* needs a `BackHandler` and the caller's `navActive` gate while the touch one needs neither.
* One prompt. [actions] is ordered PRIMARY FIRST the first (or the one flagged primary) becomes
* `confirmButton`, the rest lay out beside it.
*/
@Composable
fun PunktfunkDialog(
gamepadUi: Boolean,
title: String,
onDismiss: () -> Unit,
actions: List<DialogAction>,
@@ -62,10 +53,6 @@ fun PunktfunkDialog(
dismissOnOutsideTap: Boolean = true,
body: @Composable ColumnScope.() -> Unit,
) {
if (gamepadUi) {
GamepadDialog(title = title, onDismiss = onDismiss, actions = actions, body = body)
return
}
val primary = actions.firstOrNull { it.primary } ?: actions.firstOrNull()
val rest = actions.filter { it !== primary }
AlertDialog(
@@ -90,28 +77,25 @@ fun PunktfunkDialog(
)
}
/** A prompt's body paragraph, dimmed to sit under the title in either interface. */
/** A prompt's body paragraph, dimmed to sit under the title. */
@Composable
private fun PromptText(text: String, gamepadUi: Boolean) {
val ink = LocalGamepadInk.current
private fun PromptText(text: String) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = if (gamepadUi) ink.fg(0.7f) else MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** First connection to a host that advertised pair=optional: offer TOFU, but pitch PIN pairing. */
@Composable
fun TrustNewHostPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onTrust: () -> Unit,
onPairInstead: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
@@ -120,12 +104,11 @@ fun TrustNewHostPrompt(
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("First connection to ${pt.host}:${pt.port}.", gamepadUi)
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}", gamepadUi) }
PromptText("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { PromptText("Fingerprint ${it.take(16)}") }
PromptText(
"This host allows trust-on-first-use, but that can't tell an impostor from the real " +
"host. Pairing with a PIN is stronger — it proves both sides.",
gamepadUi,
)
}
}
@@ -133,13 +116,11 @@ fun TrustNewHostPrompt(
/** The pinned fingerprint no longer matches — force re-pairing (never a silent re-trust). */
@Composable
fun FingerprintChangedPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onRepair: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
@@ -151,7 +132,6 @@ fun FingerprintChangedPrompt(
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. " +
"This can mean a host reinstall — or an impostor. Re-pair with the host's PIN to " +
"continue.",
gamepadUi,
)
}
}
@@ -163,14 +143,12 @@ fun FingerprintChangedPrompt(
*/
@Composable
fun RequestAccessPrompt(
gamepadUi: Boolean,
pt: PendingTrust,
onRequestAccess: () -> Unit,
onUsePin: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
@@ -179,11 +157,10 @@ fun RequestAccessPrompt(
DialogAction("Cancel", onClick = onDismiss),
),
) {
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.", gamepadUi)
PromptText("${pt.host}:${pt.port} requires pairing before it will stream.")
PromptText(
"Request access and approve this device in the host's console (or web UI) — no PIN " +
"needed. Or pair with the 4-digit PIN the host displays.",
gamepadUi,
)
}
}
@@ -197,10 +174,8 @@ fun RequestAccessPrompt(
* a decision to abandon it.
*/
@Composable
fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -> Unit) {
val ink = LocalGamepadInk.current
fun AwaitingApprovalPrompt(hostLabel: String, onCancel: () -> Unit) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
@@ -216,17 +191,16 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.primary,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Approve this device on $hostLabel.",
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface,
)
}
PromptText(
"Open the host's console (or web UI) and approve “$label”. It connects " +
"automatically once you approve — no PIN needed.",
gamepadUi,
)
}
}
@@ -238,13 +212,11 @@ fun AwaitingApprovalPrompt(gamepadUi: Boolean, hostLabel: String, onCancel: () -
*/
@Composable
fun LocalNetworkPrompt(
gamepadUi: Boolean,
onAllow: () -> Unit,
onSettings: () -> Unit,
onDismiss: () -> Unit,
) {
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Allow local network access",
onDismiss = onDismiss,
actions = listOf(
@@ -256,12 +228,10 @@ fun LocalNetworkPrompt(
PromptText(
"Android blocks Punktfunk from talking to devices on your network, so it can't find " +
"or reach any host until you allow it.",
gamepadUi,
)
PromptText(
"If no prompt appears after you allow it, enable “Nearby devices” for Punktfunk in " +
"system settings.",
gamepadUi,
)
}
}
@@ -274,17 +244,14 @@ fun LocalNetworkPrompt(
*/
@Composable
fun SpeedTestPrompt(
gamepadUi: Boolean,
hostName: String,
target: SpeedTestTarget,
phase: SpeedTestPhase,
onApply: (toProfile: Boolean) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
val done = phase as? SpeedTestPhase.Done
PunktfunkDialog(
gamepadUi = gamepadUi,
title = "Network speed test",
onDismiss = onDismiss,
// Measuring bursts traffic for two seconds; a tap outside must not abandon it midway.
@@ -308,31 +275,29 @@ fun SpeedTestPrompt(
add(DialogAction("Close", primary = done == null, onClick = onDismiss))
},
) {
PromptText(hostName, gamepadUi)
PromptText(hostName)
when (phase) {
SpeedTestPhase.Connecting -> PromptText("Connecting…", gamepadUi)
SpeedTestPhase.Connecting -> PromptText("Connecting…")
SpeedTestPhase.Measuring ->
PromptText(
"Measuring — the host is bursting test traffic for two seconds.",
gamepadUi,
)
is SpeedTestPhase.Failed -> Text(
phase.message,
style = MaterialTheme.typography.bodyMedium,
color = if (gamepadUi) ink.danger else MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.error,
)
is SpeedTestPhase.Done -> {
Text(
"%.0f Mbit/s measured · %.1f %% loss".format(phase.measuredMbps, phase.lossPct),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = if (gamepadUi) ink.fg else MaterialTheme.colorScheme.onSurface,
color = MaterialTheme.colorScheme.onSurface,
)
PromptText(
"Recommended bitrate: %.0f Mbit/s".format(phase.recommendedMbps),
gamepadUi,
)
PromptText(speedTestTargetNote(target), gamepadUi)
PromptText(speedTestTargetNote(target))
}
}
}
@@ -49,13 +49,19 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import android.widget.Toast
import io.unom.punktfunk.kit.link.DeepLinkResult
import io.unom.punktfunk.kit.link.DeepLinks
import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.console.SkiaConsole
import io.unom.punktfunk.console.SkiaConsoleShell
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.models.Tab
import kotlin.math.roundToInt
@@ -76,16 +82,48 @@ fun App(forceGamepadUi: Boolean = false) {
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
// Which host's game library the TOUCH shell is showing, and which of its pinned cards opened it
// (null = the host's own card). Held here beside `tab` rather than inside the tab content: it is
// a PUSH over the whole shell, and a `remember` down in ConnectScreen would not survive the
// stream that a launch off the shelf starts — which is exactly what `reopenLibrary` restores.
var touchLibrary by remember { mutableStateOf<Pair<KnownHost, String?>?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
// connect/disconnect — unless the mode is Always, where it simply stays.
val tv = remember { isTvDevice(context) }
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(
// …AND the native console host is in this build (every shipping ABI today; the sysprop
// `debug.punktfunk.console_backend=none` forces the touch UI for on-glass triage). Without a
// console to draw, a controller drives the touch UI through Compose's own focus.
val skiaConsole = remember { SkiaConsole.wanted() }
// …AND it actually came up: a console whose native create failed or whose render thread died
// ([SkiaConsole.healthy], observable) would front a SurfaceView nothing ever paints — a gray
// screen with a working pad probe, which is worse than the touch UI it replaced.
val gamepadUi = skiaConsole && SkiaConsole.healthy && gamepadUiActive(
settings.gamepadUiEnabled, settings.gamepadUiMode, controllerConnected, tv, forceGamepadUi,
)
// System bars have ONE owner: this effect. The stream and the console shell both want the
// whole panel (bars hidden, a swipe shows them transiently); the touch shell wants them back.
// It cannot live inside the screens themselves: `AnimatedContent` below keeps the outgoing
// screen composed until its fade ends, so a per-screen `onDispose { show(...) }` fired AFTER
// the incoming screen's hide — console → stream left the status and gesture bars parked over
// the video. Keyed on the resolved intent, not the screens.
val immersive = session != null || gamepadUi
DisposableEffect(immersive) {
val window = activity?.window ?: return@DisposableEffect onDispose {}
val controller = WindowCompat.getInsetsController(window, window.decorView)
if (immersive) {
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
} else {
controller.show(WindowInsetsCompat.Type.systemBars())
}
onDispose {}
}
// Publish the live session process-wide, so a `punktfunk://` link that arrives as a SECOND
// activity instance (the normal case under `launchMode = standard`) can refuse it before that
// instance is ever resumed — see MainActivity.onCreate. Cleared on dispose, so an activity
@@ -116,6 +154,21 @@ fun App(forceGamepadUi: Boolean = false) {
}
}
// The touch shell's half of "come back to the library this game was launched from" — the console
// shell consumes the same intent on its way in (see GamepadShell). Gated on which shell is up so
// exactly one of the two ever claims it, and cleared here so a later manual Back stays backed
// out. A host forgotten while the game ran simply leaves us on the host grid.
LaunchedEffect(reopenLibrary, gamepadUi) {
if (gamepadUi) return@LaunchedEffect
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
KnownHostStore(context).all().firstOrNull { it.id == id }?.let { kh ->
// A pin unpinned while the game was running is no longer a shelf: fall back to the
// host's own, rather than a card that no longer exists.
touchLibrary = kh to pinId?.takeIf { it in kh.pinnedProfileIds }
}
reopenLibrary = null
}
// The console backdrop's colour family, published once from the live settings rather than
// threaded through every screen that draws a backdrop. Because it is read from the SAME
// `settings` state the gamepad settings screen writes, stepping the Background row recolours
@@ -146,10 +199,27 @@ fun App(forceGamepadUi: Boolean = false) {
} else {
null
}
// The console keeps its stack across the stream and wants to know how the session
// ended — a clean end is no toast, an abnormal one says why (the desktop shell's
// exact contract).
if (skiaConsole) {
SkiaConsole.sessionEnded(
when (reason) {
SessionEndReason.NONE, SessionEndReason.LOCAL,
SessionEndReason.GAME_EXITED, SessionEndReason.HOST_ENDED -> null
SessionEndReason.HOST_ERROR -> "the host reported an error"
SessionEndReason.LOST -> "the connection was lost"
},
)
}
session = null
}
} else if (gamepadUi) {
GamepadShell(
// The console: the same Skia shell the Linux/Windows session binary shows, drawn by
// native onto a SurfaceView (design/android-skia-console-port.md) — the Compose
// console it replaced is gone. `gamepadUi` already folds in whether the native host
// is present on this build (see `SkiaConsole.wanted` in the `gamepadUi` resolution).
SkiaConsoleShell(
settings = settings,
onSettingsChange = { settings = it; settingsStore.save(it) },
onConnected = { session = it },
@@ -158,6 +228,19 @@ fun App(forceGamepadUi: Boolean = false) {
reopenLibrary = reopenLibrary,
onReopenLibraryHandled = { reopenLibrary = null },
)
} else if (touchLibrary != null) {
// The touch shell's library is a PUSHED screen, not a tab: it belongs to one host, and a
// third permanent tab for something you reach from a card would be a nav item that is
// meaningless until you pick one. So it takes the whole window (bar included) and Back — the arrow or the system gesture — returns to the grid.
// Read once: `touchLibrary` is a `var`, so it does not smart-cast through the branch.
val (host, pinId) = touchLibrary!!
LibraryScreen(
host = host,
settings = settings,
onLaunched = { session = it },
onBack = { touchLibrary = null },
pinnedProfileId = pinId,
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
@@ -193,6 +276,9 @@ fun App(forceGamepadUi: Boolean = false) {
onSettingsChange = { settings = it; settingsStore.save(it) },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
// "Browse library…" in a card's overflow — the touch route to the shelf
// the console shell reaches with Y.
onOpenLibrary = { kh, pinId -> touchLibrary = kh to pinId },
)
Tab.Settings -> SettingsScreen(
initial = settings,
@@ -254,194 +340,6 @@ fun App(forceGamepadUi: Boolean = false) {
*/
val LocalGamepadPalette = compositionLocalOf { GamepadPalette.named("violet") }
/**
* Which console screen the gamepad shell is showing, and how deep it sits Home is the root, and
* everything reachable from it is one level in. The DEPTH is what decides whether a change is a
* push or a pop, and therefore which way the screens travel.
*/
private enum class GamepadScreen(val depth: Int) {
Home(0),
Settings(1),
Library(1),
// Reached FROM Settings, not from Home, so they sit a level deeper again — which is precisely
// what makes Settings → Controllers travel like a push and the way back like a pop. Give one of
// these depth 1 and the transition would read as a sideways swap between two peers.
Controllers(2),
Licenses(2),
}
/**
* The console (gamepad) shell the Android mirror of the Apple client's ContentView gamepad branch:
* a full-screen host carousel with X Settings and Y a saved host's library, all sharing
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
*/
@Composable
fun GamepadShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this library shelf instead of Home on the way in set when a game launched from it has
* just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibrary: LibraryReturn? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
var libraryPinId by remember { mutableStateOf<String?>(null) }
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
// equivalent of a browser losing your scroll position on Back. Held here because this is the
// only thing that outlives the screen.
var settingsPlace by remember { mutableStateOf<GpSettingsPlace?>(null) }
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibrary) {
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
// A pin unpinned while the game was running is no longer a shelf: fall back to the
// host's own, rather than a card that no longer exists.
?.let { kh ->
libraryHost = kh
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
screen = GamepadScreen.Library
}
onReopenLibraryHandled()
}
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
// wanted — one uniform factor across text, cards, spacing, and insets.
val isTv = remember { isTvDevice(context) }
val baseDensity = LocalDensity.current
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
// The console's screen transition, and the desktop console's contract rather than a plain
// cross-fade (see ConsoleMotion for the numbers and where they come from): a PUSH slides the
// incoming screen up out of a fade while the outgoing one recedes; a POP runs it backwards, the
// leaving screen sliding down and the revealed one growing back. Direction comes from the
// screens' nav DEPTH, so Settings → Home pops even though nothing tracks a stack.
//
// Each slot's controller nav is gated on being the CURRENT target (`s == screen`), so mid-
// transition only the incoming screen drives the pad. All screens pin their legend at the same
// ConsoleLegendInset, so it reads as fixed while the content behind it moves.
val animated = animationsEnabled()
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
// Measured INSIDE the console's own density, not the device's: on a TV the console UI runs at a
// reduced density to shrink the 10-foot layout, and a slide sized in device pixels would travel
// further than every other dp in the same animation.
val slidePx = with(LocalDensity.current) { ConsoleMotion.PUSH_SLIDE.toPx() }.roundToInt()
AnimatedContent(
targetState = screen,
transitionSpec = {
if (!animated) {
// Reduce-motion: no travel, no scale — just a fast cross-fade, the same courtesy
// the frozen backdrop pays.
fadeIn(tween(ConsoleMotion.REDUCED_MS)) togetherWith
fadeOut(tween(ConsoleMotion.REDUCED_MS))
} else if (targetState.depth > initialState.depth) {
(
fadeIn(ConsoleMotion.ease()) +
slideInVertically(ConsoleMotion.ease()) { slidePx } +
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.ENTER_SCALE)
) togetherWith (
fadeOut(ConsoleMotion.ease()) +
scaleOut(ConsoleMotion.ease(), targetScale = ConsoleMotion.EXIT_SCALE)
)
} else {
(
fadeIn(ConsoleMotion.ease(), initialAlpha = ConsoleMotion.REVEAL_ALPHA) +
scaleIn(ConsoleMotion.ease(), initialScale = ConsoleMotion.EXIT_SCALE)
) togetherWith (
fadeOut(ConsoleMotion.ease()) +
slideOutVertically(ConsoleMotion.ease()) { slidePx }
)
}
},
label = "consoleScreen",
) { s ->
when (s) {
GamepadScreen.Home -> ConnectScreen(
settings = settings,
onConnected = onConnected,
onSettingsChange = onSettingsChange,
deepLink = deepLink,
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host, pinId ->
libraryHost = host
libraryPinId = pinId
screen = GamepadScreen.Library
},
navGate = s == screen,
)
GamepadScreen.Settings -> GamepadSettingsScreen(
initial = settings,
onChange = onSettingsChange,
// Leaving for HOME forgets the place: coming back in from the carousel should start
// at the top of the first section, exactly as it always has. Only a sub-screen's
// Back is a return.
onBack = { screen = GamepadScreen.Home; settingsPlace = null },
navActive = s == screen,
resume = settingsPlace,
onPlace = { settingsPlace = it },
onOpenControllers = { screen = GamepadScreen.Controllers },
onOpenLicenses = { screen = GamepadScreen.Licenses },
)
GamepadScreen.Controllers -> ConsoleControllersScreen(
gamepadSetting = settings.gamepad,
onBack = { screen = GamepadScreen.Settings },
navActive = s == screen,
)
GamepadScreen.Licenses -> ConsoleLicensesScreen(
onBack = { screen = GamepadScreen.Settings },
navActive = s == screen,
)
GamepadScreen.Library -> libraryHost?.let { host ->
LibraryScreen(
host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
navActive = s == screen,
pinnedProfileId = libraryPinId,
)
} ?: run { screen = GamepadScreen.Home }
}
}
}
}
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
// --- Showing a TOUCH-written screen on the console's field -------------------------------------
//
// Two screens (Controllers, Licenses) exist once and are shown in both interfaces. They live beside
// the shell rather than in `GamepadChrome.kt` because they are about the SHELL's job — putting a
// screen that was written for one interface onto the other's field — rather than about the console's
// own material.
/**
* Re-inks a screen written against the TOUCH theme so it can be shown on the console's field.
*
@@ -82,6 +82,14 @@ internal fun ConnectGrid(
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
/** The experimental game-library toggle — off hides "Browse library…" everywhere. */
libraryEnabled: Boolean,
/**
* Open this card's game library. The second argument is the shelf's pinned profile id, exactly
* as [onConnect] takes the card's one-off: browsing IS this card's connect with a title picked
* first, so a pinned card's shelf launches with that card's profile.
*/
onBrowseLibrary: (KnownHost, StreamProfile?) -> Unit,
onRescan: () -> Unit,
onAddHost: () -> Unit,
) {
@@ -90,6 +98,13 @@ internal fun ConnectGrid(
// "Connect with" is a ONE-OFF on every card: it never rebinds the host, which is why rebinding
// lives in the Edit sheet instead.
fun hostMenu(kh: KnownHost, pin: StreamProfile?): List<HostMenuItem> = buildList {
// Browsing IS a connect-shaped action — this card's connect with a title picked first — so
// a PINNED card offers it too, and its shelf launches with that card's profile. Without it
// the touch home had no route to the library at all: the console shell reaches it with Y
// from a tile, and a finger has no Y.
if (libraryEnabled) {
add(HostMenuItem("Browse library…") { onBrowseLibrary(kh, pin) })
}
if (pin == null) {
add(HostMenuItem("Network speed test") { onSpeedTest(kh) })
}
@@ -108,7 +108,6 @@ private fun connectCopy(phase: ConnectPhase): ConnectCopy = when (phase) {
fun ConnectOverlay(
connectingHostName: String?,
waker: WakeController,
gamepadUi: Boolean,
onCancelConnect: () -> Unit,
) {
val waking = waker.waking
@@ -124,19 +123,8 @@ fun ConnectOverlay(
// System Back / pad B (remapped) cancels whatever's in flight — a plain dial or the wake wait.
val cancel = { if (waking != null) waker.cancel() else onCancelConnect() }
if (gamepadUi) {
BackHandler { cancel() }
// A retries once a wake has timed out; B falls through to the BackHandler above.
GamepadNavEffect2D(
active = true,
onDirection = {},
onActivate = { if (phase is ConnectPhase.WakeTimedOut) waker.retry() },
)
ConnectTakeover(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
} else {
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
}
// The AlertDialog owns its own scrim + system-Back handling (routed to cancel).
ConnectModal(phase = phase, onCancel = cancel, onRetry = { waker.retry() })
}
/**
@@ -179,117 +167,3 @@ internal fun ConnectModal(
},
)
}
/**
* The console / gamepad presentation: an opaque aurora backdrop with a centred spinner/title/subtitle
* for [phase], plus a bottom hint bar spelling out the pad actions (B cancels, A retries once timed
* out) glyph-driven like every other console screen. onClick keeps the hints tappable too, so a
* user without a working pad can still get out.
*/
@Composable
internal fun ConnectTakeover(
phase: ConnectPhase,
onCancel: () -> Unit,
onRetry: () -> Unit,
) {
val ink = LocalGamepadInk.current
val copy = connectCopy(phase)
val timedOut = phase is ConnectPhase.WakeTimedOut
Box(
Modifier
.fillMaxSize()
// Swallow taps so the screen behind can't be touched through the takeover.
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
contentAlignment = Alignment.Center,
) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(
// The backdrop runs full-bleed; the COPY keeps clear of the bars and the cutout. In
// landscape a hole punch is a side inset deeper than this 40 dp gutter, so centred text
// would otherwise sit under the camera.
Modifier.consoleSafeArea().padding(horizontal = 40.dp).widthIn(max = 460.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
if (timedOut) {
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = ink.fg(0.9f),
modifier = Modifier.size(46.dp),
)
}
} else {
PulsingSpinner()
}
Text(
copy.title,
color = ink.fg,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
textAlign = TextAlign.Center,
)
Text(
copy.subtitle,
color = ink.fg(0.65f),
fontSize = 14.sp,
textAlign = TextAlign.Center,
fontFamily = if (copy.monoSubtitle) FontFamily.Monospace else FontFamily.Default,
)
}
val hints = buildList {
add(PadGlyph.hint('B', copy.cancelLabel, onClick = onCancel))
if (timedOut) add(PadGlyph.hint('A', "Try Again", onClick = onRetry))
}
// The SAME bottom-start spot every console screen pins its legend at — this takeover sat
// its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that is
// supposed to read as fixed jump halfway across the screen (second on-glass verdict).
val landscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(hints)
}
}
}
/**
* The connecting/waking indicator: a white progress ring inside two brand-violet halo rings that
* expand and fade on a staggered loop a small sign of life so the takeover reads as working, not
* stalled.
*/
@Composable
private fun PulsingSpinner() {
val ink = LocalGamepadInk.current
val transition = rememberInfiniteTransition(label = "connectPulse")
val pulse by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Restart),
label = "pulse",
)
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val maxR = size.minDimension / 2f
for (i in 0..1) {
val p = (pulse + i * 0.5f) % 1f
drawCircle(
color = ink.accent.copy(alpha = (1f - p) * 0.35f),
radius = maxR * (0.42f + p * 0.58f),
style = Stroke(width = 2.dp.toPx()),
)
}
}
CircularProgressIndicator(
color = ink.fg,
strokeWidth = 3.dp,
modifier = Modifier.size(54.dp),
)
}
}
@@ -20,7 +20,6 @@ import io.unom.punktfunk.models.PendingTrust
*/
@Composable
internal fun ConnectPrompts(
gamepadUi: Boolean,
/** The client identity — the PIN ceremony needs it to run SPAKE2; null while it is still minting. */
identity: ClientIdentity?,
profiles: List<StreamProfile>,
@@ -38,17 +37,6 @@ internal fun ConnectPrompts(
/** Non-null while a "request access" connect sits parked on the host awaiting approval. */
awaitingHostName: String?,
onCancelApproval: () -> Unit,
// ---- console host options (Up on a saved carousel tile) ---------------------------------
optionsTarget: HostCardEntry?,
onDismissOptions: () -> Unit,
libraryEnabled: Boolean,
onOpenLibrary: (KnownHost, String?) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
onEditHost: (KnownHost) -> Unit,
onForgetHost: (KnownHost) -> Unit,
onTogglePin: (KnownHost, StreamProfile) -> Unit,
// ---- speed test --------------------------------------------------------------------------
speedTest: HostCardEntry?,
/** Which layer Apply writes to. Resolved by the caller (it holds the store); set with [speedTest]. */
@@ -81,104 +69,51 @@ internal fun ConnectPrompts(
// against four D-pad digit slots is a different input model, not a different skin.
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> TrustNewHostPrompt(
gamepadUi, pt,
pt,
onTrust = { onTrustNew(pt) },
onPairInstead = onPair,
onDismiss = { onPendingTrustChange(null) },
)
PendingTrust.Kind.FP_CHANGED ->
FingerprintChangedPrompt(gamepadUi, pt, onPair) { onPendingTrustChange(null) }
FingerprintChangedPrompt(pt, onPair) { onPendingTrustChange(null) }
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessPrompt(
gamepadUi, pt,
pt,
onRequestAccess = { onRequestAccess(pt) },
onUsePin = onPair,
onDismiss = { onPendingTrustChange(null) },
)
PendingTrust.Kind.PAIR -> {
val onSavePaired = { fp: String -> onPaired(pt, fp) }
if (gamepadUi) {
GamepadPairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
} else {
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
}
PairPinDialog(pt, identity, onSavePaired) { onPendingTrustChange(null) }
}
}
}
awaitingHostName?.let { hostLabel ->
AwaitingApprovalPrompt(gamepadUi, hostLabel = hostLabel, onCancel = onCancelApproval)
}
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { entry ->
val kh = entry.host
val pin = entry.pin
val offline = !isOnline(kh)
GamepadHostOptionsDialog(
hostName = kh.name,
canWake = kh.mac.isNotEmpty() && offline,
onWake = { onDismissOptions(); onWake(kh) },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button. A PIN card
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
// start the card, not a property of the host.
onLibrary = if (libraryEnabled) {
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
} else {
null
},
onSpeedTest = if (pin == null) {
{ onDismissOptions(); onSpeedTest(kh) }
} else {
null
},
onCopyLink = { onDismissOptions(); onCopyLink(kh, pin) },
onEdit = { onDismissOptions(); onEditHost(kh) },
onForget = { onForgetHost(kh); onDismissOptions() },
onDismiss = onDismissOptions,
// A pin's only action: unpinning touches neither the host nor the profile.
onUnpin = pin?.let { p -> { onTogglePin(kh, p); onDismissOptions() } },
profileName = pin?.name,
)
AwaitingApprovalPrompt(hostLabel = hostLabel, onCancel = onCancelApproval)
}
if (speedTest != null && speedTestTarget != null) {
SpeedTestPrompt(
gamepadUi, speedTest.host.name, speedTestTarget, speedTestPhase,
speedTest.host.name, speedTestTarget, speedTestPhase,
onApplySpeedTest, onDismissSpeedTest,
)
}
editTarget?.let { kh ->
if (gamepadUi) {
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
// host with an extra MAC row; the action SAVES instead of connecting.
GamepadAddHostScreen(
onAdd = { _, _, _ -> },
onDismiss = onDismissEdit,
editHost = kh,
suggestedMacs = editSuggestedMacs,
onSave = onSaveHost,
// Shared clipboard and the profile binding — the two host decisions that used to
// exist only in the touch edit sheet, which a TV box has no way to reach.
profiles = profiles,
)
} else {
EditHostDialog(
target = kh,
suggestedMacs = editSuggestedMacs,
profiles = profiles,
onSave = onSaveHost,
onDismiss = onDismissEdit,
)
}
EditHostDialog(
target = kh,
suggestedMacs = editSuggestedMacs,
profiles = profiles,
onSave = onSaveHost,
onDismiss = onDismissEdit,
)
}
if (lnpPrompt) {
// Android 17+ local-network-permission rationale: re-request (a permanently-denied request
// returns instantly without a system prompt — hence the settings deep link alongside).
LocalNetworkPrompt(
gamepadUi,
onAllow = onAllowLocalNetwork,
onSettings = onOpenSystemSettings,
onDismiss = onDismissLnpPrompt,
@@ -186,12 +121,10 @@ internal fun ConnectPrompts(
}
// Topmost: the full-screen connect takeover — instant "Connecting…" feedback on any dial, flowing
// seamlessly into the "Waking…" wait if the host turns out to be asleep. Rides over both the touch
// grid and the console home.
// seamlessly into the "Waking…" wait if the host turns out to be asleep.
ConnectOverlay(
connectingHostName = connectingHostName,
waker = waker,
gamepadUi = gamepadUi,
onCancelConnect = onCancelConnect,
)
}
@@ -1,8 +1,6 @@
package io.unom.punktfunk
import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
@@ -95,15 +93,10 @@ fun ConnectScreen(
// Writes the global defaults back. Only the speed test uses it — that is the one action on this
// screen that can land in the defaults layer (design/client-settings-profiles.md §5.3).
onSettingsChange: (Settings) -> Unit = {},
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
gamepadUi: Boolean = false,
onOpenSettings: () -> Unit = {},
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
// Raised by "Browse library…" in a card's overflow.
onOpenLibrary: (KnownHost, String?) -> Unit = { _, _ -> },
navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
// link must go through all of them, not around them.
@@ -295,9 +288,6 @@ fun ConnectScreen(
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
// A saved host being edited (name / address / port / MAC).
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
// carousel (the console counterpart of the touch host card's overflow menu).
var optionsTarget by remember { mutableStateOf<HostCardEntry?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
@@ -635,22 +625,11 @@ fun ConnectScreen(
// host's binding, exactly like a tap on it does.
fun copyLink(kh: KnownHost, pin: StreamProfile?) {
val url = DeepLinks.forHost(kh, profile = pin?.id).toUrl()
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val copied = clipboard != null && runCatching {
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
}.isSuccess
// Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is
// the platform's own documented anti-pattern. Below it nothing visible happens at all unless
// we say so — a silent menu item reads as a broken one.
if (copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) return
val message = if (copied) "Link copied." else "Couldn't copy the link to the clipboard."
// The console home renders neither the notice nor the status banner, so there it has to be a
// toast; the touch grid has both, and a success dressed as an error banner is a small lie.
when {
gamepadUi -> Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
copied -> notice = message
else -> status = message
}
val copied = putLinkOnClipboard(context, url)
val message = linkCopyMessage(copied) ?: return
// A success dressed as an error banner is a small lie: the notice line for a copy, the
// status line for a failure.
if (copied) notice = message else status = message
}
// ---- punktfunk:// routing (design/client-deep-links.md §3) --------------------------------
@@ -762,101 +741,57 @@ fun ConnectScreen(
fun forgetHost(kh: KnownHost) {
knownHostStore.remove(kh)
// A forgotten host leaves no list of what somebody plays, and no record of what they were
// playing, behind on the device. Its record id is the key both are filed under, so this is
// the last moment either can be found.
io.unom.punktfunk.kit.library.LibraryCache.standard(context.cacheDir).forget(kh.id)
LibraryPosition.forget(context, kh.id)
savedHosts = knownHostStore.all()
}
if (gamepadUi) {
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
GamepadHome(
tiles = buildHomeTiles(
savedHosts = savedHosts,
profiles = profiles,
pinsFor = profileStore::pinsFor,
discoveredUnsaved = discoveredUnsaved,
isOnline = { it.isOnline(discovered, reachable) },
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
onAddHost = { showManualSheet = true },
),
libraryEnabled = settings.libraryEnabled,
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
// console home is cross-fading out.
// ⚠ `speedTest` belongs in this list and was missing. It LOOKED covered by `!connecting`,
// and is — right up until the measurement finishes: `startSpeedTest` clears `connecting`
// before its Done/Failed card is dismissed, so from that moment the card AND the
// carousel underneath both consumed the pad. One A then dismissed the card and started
// a connect. Every other modal on this screen is named here for exactly this reason.
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
awaiting == null && editTarget == null && optionsTarget == null &&
speedTest == null && waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
onOpenSettings = onOpenSettings,
onOptions = { tile ->
tile.knownHost?.let { kh ->
optionsTarget = HostCardEntry(kh, tile.pinnedProfileId?.let(profileStore::byId))
}
},
)
} else {
ConnectGrid(
savedHosts = savedHosts,
discovered = discovered,
discoveredUnsaved = discoveredUnsaved,
reachable = reachable,
profiles = profiles,
pinsFor = profileStore::pinsFor,
connecting = connecting,
notice = notice,
status = status,
lnpGranted = lnpGranted,
onAskLocalNetwork = { lnpPrompt = true },
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
onForget = { kh -> forgetHost(kh) },
onEdit = { kh -> editTarget = kh },
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onTogglePin = { kh, p -> togglePin(kh, p) },
onRescan = { discovery.restart() },
onAddHost = { showManualSheet = true },
)
}
ConnectGrid(
savedHosts = savedHosts,
discovered = discovered,
discoveredUnsaved = discoveredUnsaved,
reachable = reachable,
profiles = profiles,
pinsFor = profileStore::pinsFor,
connecting = connecting,
notice = notice,
status = status,
lnpGranted = lnpGranted,
onAskLocalNetwork = { lnpPrompt = true },
onConnect = { kh, oneOff -> connect(kh.address, kh.port, oneOffProfile = oneOff) },
onConnectDiscovered = { dh -> connect(dh.host, dh.port, dh) },
onForget = { kh -> forgetHost(kh) },
onEdit = { kh -> editTarget = kh },
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onTogglePin = { kh, p -> togglePin(kh, p) },
libraryEnabled = settings.libraryEnabled,
onBrowseLibrary = { kh, pin -> onOpenLibrary(kh, pin?.id) },
onRescan = { discovery.restart() },
onAddHost = { showManualSheet = true },
)
// Add Host stayed behind while the other modals moved into ConnectPrompts: its form fields are
// remembered HERE, on purpose, so a half-typed address survives the sheet being dismissed and
// reopened. Moving the block without moving that state would quietly change what a dismiss
// costs; moving both is a separate decision from this one.
if (showManualSheet) {
if (gamepadUi) {
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
GamepadAddHostScreen(
onAdd = { n, addr, p ->
showManualSheet = false
connect(addr, p, manualName = n)
},
onDismiss = { showManualSheet = false },
)
} else {
AddHostSheet(
hostName = hostName,
onHostNameChange = { hostName = it },
host = host,
onHostChange = { host = it },
port = port,
onPortChange = { port = it },
connecting = connecting,
modeLabel = "$w×$h@$hz",
onDismiss = { showManualSheet = false },
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
)
}
AddHostSheet(
hostName = hostName,
onHostNameChange = { hostName = it },
host = host,
onHostChange = { host = it },
port = port,
onPortChange = { port = it },
connecting = connecting,
modeLabel = "$w×$h@$hz",
onDismiss = { showManualSheet = false },
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
)
}
// Which layer a measurement would land in. Resolved here, not in the prompt: it is a question
@@ -870,7 +805,6 @@ fun ConnectScreen(
// Everything that floats above whichever home was drawn, in one place and in one order — see
// ConnectPrompts.kt. It decides nothing: each action below lands right back in the engine above.
ConnectPrompts(
gamepadUi = gamepadUi,
identity = identity,
profiles = profiles,
isOnline = { it.isOnline(discovered, reachable) },
@@ -894,16 +828,6 @@ fun ConnectScreen(
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
},
optionsTarget = optionsTarget,
onDismissOptions = { optionsTarget = null },
libraryEnabled = settings.libraryEnabled,
onOpenLibrary = onOpenLibrary,
onWake = { kh -> wakeHost(kh) },
onSpeedTest = { kh -> startSpeedTest(HostCardEntry(kh, null)) },
onCopyLink = { kh, pin -> copyLink(kh, pin) },
onEditHost = { kh -> editTarget = kh },
onForgetHost = { kh -> forgetHost(kh) },
onTogglePin = { kh, p -> togglePin(kh, p) },
speedTest = speedTest,
speedTestTarget = speedTestTarget,
speedTestPhase = speedTestPhase,
@@ -953,9 +877,6 @@ fun ConnectScreen(
* One entry in the saved-hosts grid: a host's own card ([pin] null), or one of its pinned
* host+profile cards. Pins are additive presentation state on the host record never duplicated
* host entries, which would fork pairing, trust and renames (design §5.2a).
*
* The console reuses it deliberately: its options dialog acts on a host-or-pin exactly as the touch
* card's overflow menu does, and one currency for "which card is this" keeps the two from drifting.
*/
internal data class HostCardEntry(val host: KnownHost, val pin: StreamProfile?) {
val key: String get() = "card-${host.id}-${pin?.id ?: "primary"}"
@@ -1,7 +1,6 @@
package io.unom.punktfunk
import android.content.Context
import android.content.res.Configuration
import android.hardware.input.InputManager
import android.os.Build
import android.os.CombinedVibration
@@ -14,7 +13,6 @@ import android.view.MotionEvent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -49,11 +47,8 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2BleLink
@@ -61,158 +56,34 @@ import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
/**
* Connected-controllers debug view (Settings Host Connected controllers): everything the app
* can see about attached input devices, plus a live input test. This exists for exactly the support
* case where a pad "doesn't work" adapters and BT-to-USB dongles often enumerate with a different
* identity than the physical pad, or not as a gamepad at all, and punktfunk only forwards devices
* Android classifies as gamepad/joystick. This screen makes that visible on the device itself.
* Connected-controllers debug view (Settings -> Controller -> Connected controllers): everything
* the app can see about attached input devices, plus a live input test. This exists for exactly
* the support case where a pad "doesn't work" - adapters and BT-to-USB dongles often enumerate
* with a different identity than the physical pad, or not as a gamepad at all, and punktfunk only
* forwards devices Android classifies as gamepad/joystick. This screen makes that visible on the
* device itself.
*
* This is the TOUCH entry point; [ConsoleControllersScreen] shows the same body on the console's
* field. Both drive [ControllersBody] the screen exists once, and the support answer it gives has
* to be the same one whichever interface asked.
* The TOUCH presentation, and since 2026-08 the only one: the console reaches the same answer
* through its own Skia screen (`crates/pf-console-ui/src/screens/controllers.rs`), which keeps the
* console's input on the page instead of suspending it behind a Compose takeover. What this screen
* still owns alone is the live input test - the console receives only the aggregated navigation
* sample, which is nowhere near a per-device axis/trigger readout. Everything the console DOES
* need from here it asks for as a `ConsoleCmd::PadAction` (see [SkiaConsoleShell]), which is why
* [padInfoOf] and [testRumble] are internal rather than private.
*/
@Composable
internal fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit, padsOverride: List<PadInfo>? = null) {
BackHandler(onBack = onBack)
var testing by remember { mutableStateOf(false) }
ControllersBody(
gamepadSetting = gamepadSetting,
scroll = rememberScrollState(),
testing = testing,
onTestingChange = { testing = it },
padsOverride = padsOverride,
// The touch screen holds the probes for its whole life: events are OBSERVED (not consumed)
// while the test is off, which is what keeps the "Last input" line live while browsing.
// Nothing else here wants the pad, so there is no one to hand them to.
observeInput = true,
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp),
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
}
}
/**
* The same screen on the console's field the couch route to it, which a TV box has no other way to
* reach (there is no touch interface to fall back to there, which is exactly why this matters).
*
* Navigation, and how the pad is shared with the test:
* * up/down scrolls, the shoulders page the body is cards and prose with no focusable rows, and
* Compose only scrolls to keep a FOCUSED child visible (see [rememberConsoleScroller]);
* * A starts the input test, which is the one thing on this screen a controller can act on;
* * while the test runs it OWNS the pad that is the whole point of it so this screen's nav
* drops out of the probe slots and B is a HOLD (below). Everything reverts the moment it ends.
*/
@Composable
internal fun ConsoleControllersScreen(
internal fun ControllersScreen(
gamepadSetting: Int,
onBack: () -> Unit,
navActive: Boolean = true,
padsOverride: List<PadInfo>? = null,
) {
BackHandler(onBack = onBack)
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val scroll = rememberScrollState()
val scrollBy = rememberConsoleScroller(scroll)
// Events are OBSERVED (not consumed) while the test is off, which is what keeps the
// "Last input" line live while browsing. Nothing else here wants the pad.
var testing by remember { mutableStateOf(false) }
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
GamepadNavEffect2D(
// Off while the test runs: both want the same single probe slot, and the test is the one
// the user just asked for. The identity check in each teardown (here and in the body) is
// what makes the handover safe in either direction.
active = navActive && !testing,
onDirection = { dir ->
when (dir) {
NavDir.UP -> scrollBy(-1, false)
NavDir.DOWN -> scrollBy(1, false)
// Nothing on this screen steps sideways; paging is the shoulders' job.
NavDir.LEFT, NavDir.RIGHT -> {}
}
},
onActivate = { testing = true },
onShoulder = { delta -> scrollBy(delta, true) },
)
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
// The calm backdrop, full-bleed under the bars and the cutout: this is a screen to READ,
// and the aurora is ambience. Only the content takes the safe area.
GamepadFormBackground(Modifier.fillMaxSize())
// The body is written against the touch theme; on the console field it has to be inked
// from the palette or it is grey-on-pastel over the six pale palettes.
ConsoleInkedTheme {
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ControllersBody(
gamepadSetting = gamepadSetting,
scroll = scroll,
testing = testing,
onTestingChange = { testing = it },
padsOverride = padsOverride,
// Only while testing: the rest of the time the screen's own nav holds the
// probes, so the "Last input" line is a test-time readout here rather than
// an always-on one. A pad that reaches this screen at all has already
// proved it is seen — by moving the cursor here.
observeInput = testing,
contentPadding = PaddingValues(
start = ConsoleEdgeInset,
end = ConsoleEdgeInset,
// Clears the floating legend zone, like every other console list.
bottom = ConsoleLegendClearance,
),
) {
ConsoleHeader("Connected controllers", horizontalInset = false)
}
}
}
}
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
if (testing) {
// The rule, stated at the moment it applies: while the test runs, B is a BUTTON
// UNDER TEST like any other — it lights its own chip — so only a hold ends the
// test, after which B is the universal Back again. Tappable as the touch hatch.
listOf(PadGlyph.hint('B', "Hold to finish") { testing = false })
} else {
listOfNotNull(
GamepadHint('↕', PadGlyph.Arrow, "Scroll"),
// Advertised only where they exist — a TV remote has no shoulders, and
// claiming otherwise is both a lie and the reason a narrow legend overflows.
GamepadHint('⇄', PadGlyph.Arrow, "Page").takeIf { padIsGamepad },
PadGlyph.hint('A', "Test inputs") { testing = true },
PadGlyph.hint('B', "Done", onClick = onBack),
)
},
hazeState = hazeState,
)
}
}
}
/**
* The screen itself, shared by both interfaces. [contentPadding] and [heading] are where they
* differ: the touch screen pads for a thumb and titles with the Material headline, the console pads
* to the shared edge inset, clears its floating legend, and titles with [ConsoleHeader].
*
* [observeInput] decides whether this body installs the shared MainActivity probes at all see the
* two call sites, and [ConsoleControllersScreen] for why they cannot both be on at once.
*/
@Composable
private fun ControllersBody(
gamepadSetting: Int,
scroll: ScrollState,
testing: Boolean,
onTestingChange: (Boolean) -> Unit,
observeInput: Boolean,
contentPadding: PaddingValues,
padsOverride: List<PadInfo>? = null,
heading: @Composable () -> Unit,
) {
val onTestingChange: (Boolean) -> Unit = { testing = it }
val contentPadding = PaddingValues(horizontal = 20.dp, vertical = 24.dp)
val context = LocalContext.current
val activity = context as? MainActivity
@@ -225,7 +96,11 @@ private fun ControllersBody(
InputDevice.getDeviceIds()
.toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { !it.isVirtual && !Gamepad.isPad(it) }
// Everything real that is NOT counted as a controller — including a device that claims
// a pad source with no pad hardware behind it, which the Gamepads list above now
// rejects. One list or the other, never neither: this screen is where someone looks
// when the client's idea of "a pad is attached" disagrees with the room.
.filter { !it.isVirtual && !Gamepad.looksLikeController(it) }
}
DisposableEffect(Unit) {
val im = context.getSystemService(InputManager::class.java)
@@ -247,32 +122,37 @@ private fun ControllersBody(
var bHeld by remember { mutableStateOf(false) }
// The hold has lasted long enough; the test ends when B is let go (see the probe).
var holdSatisfied by remember { mutableStateOf(false) }
// The probes below are built ONCE per `observeInput` and then read these for the life of that
// installation. `testing` and the callback arrive as parameters now, so capturing them plainly
// would freeze the values they had when the probe was made — the test would consume nothing.
// The probes below are built ONCE and then read these for the life of the screen, so
// capturing `testing` plainly would freeze the value it had when the probe was made — the
// test would consume nothing.
val consuming by rememberUpdatedState(testing)
// The console's refusal thud, on whatever actuator the driving pad or this device has.
val haptics by rememberUpdatedState(rememberConsoleHaptics())
DisposableEffect(observeInput) {
// Stable probe refs, and a teardown that releases the slot only if WE still hold it — the
// rule GamepadNavEffect2D follows. Without it this screen's dispose nulls whatever is in the
// slot: during the console shell's push/pop BOTH screens are briefly composed, so leaving
// here would kill the pad navigation the arriving screen had just installed. The same
// teardown also runs when this screen hands the pad to its own input test and back.
DisposableEffect(Unit) {
// One entry on the MainActivity probe stack, removed by identity on the way out — the rule
// GamepadNavEffect2D follows. During the console shell's push/pop BOTH screens are briefly
// composed, and only the identity removal keeps this screen's teardown from taking the
// arriving screen's claim with it. The same teardown also runs when this screen hands the
// pad to its own input test and back.
val keyProbe: (KeyEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Read ONCE, up front: the test can end inside this very event, and the release that
// ended it still has to be swallowed here — see the B branch below.
val consume = consuming
// The CORRECTED keycode, so this screen shows the button the stream will send and not
// the one Android guessed for a pad it has no key layout for — the two differ on every
// controller [Gamepad.padKeyCode] exists for, and a tester that disagrees with the
// stream is worse than no tester. The raw pair is still reported in "Last input".
val code = Gamepad.padKeyCode(event)
when (event.action) {
KeyEvent.ACTION_DOWN -> {
held[event.keyCode] = true
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
held[code] = true
if (code == KeyEvent.KEYCODE_BUTTON_B) bHeld = true
}
KeyEvent.ACTION_UP -> {
held[event.keyCode] = false
if (event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
held[code] = false
if (code == KeyEvent.KEYCODE_BUTTON_B) {
bHeld = false
if (consume) {
if (event.eventTime - event.downTime >= HOLD_TO_FINISH_MS) {
@@ -296,37 +176,50 @@ private fun ControllersBody(
}
}
}
lastInput = "${event.device?.name}: ${KeyEvent.keyCodeToString(event.keyCode)}"
// Raw scancode AND keycode, plus the correction when one fired: this line is what a
// field report needs to pin an unmapped pad's report order without the device in hand.
val raw = KeyEvent.keyCodeToString(event.keyCode).removePrefix("KEYCODE_")
val fixed = KeyEvent.keyCodeToString(code).removePrefix("KEYCODE_")
lastInput = "${event.device?.name}: scan 0x%X · %s%s".format(
event.scanCode,
raw,
if (code != event.keyCode) "$fixed" else "",
)
consume
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ event ->
if (!Gamepad.isPad(event.device)) return@probe false
// Through the device's resolved map, exactly as `Gamepad.AxisMapper` reads it while
// streaming — on a pad Android has no key layout for, the right stick and the triggers
// are not on the axes their names suggest.
val map = Gamepad.padMap(event.device)
axes["LX"] = event.getAxisValue(MotionEvent.AXIS_X)
axes["LY"] = event.getAxisValue(MotionEvent.AXIS_Y)
axes["RX"] = event.getAxisValue(MotionEvent.AXIS_Z)
axes["RY"] = event.getAxisValue(MotionEvent.AXIS_RZ)
axes["LT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
axes["RT"] = maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
axes["RX"] = event.getAxisValue(map.rightStickX)
axes["RY"] = event.getAxisValue(map.rightStickY)
axes["LT"] = if (map.leftTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
)
} else {
map.level(event.getAxisValue(map.leftTrigger))
}
axes["RT"] = if (map.rightTrigger == Gamepad.AXIS_NONE) {
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
)
} else {
map.level(event.getAxisValue(map.rightTrigger))
}
axes["HX"] = event.getAxisValue(MotionEvent.AXIS_HAT_X)
axes["HY"] = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
consuming
}
if (observeInput) {
activity?.padKeyProbe = keyProbe
activity?.padMotionProbe = motionProbe
}
onDispose {
activity?.let { a ->
if (a.padKeyProbe === keyProbe) a.padKeyProbe = null
if (a.padMotionProbe === motionProbe) a.padMotionProbe = null
}
}
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
activity?.pushPadProbes(probes)
onDispose { activity?.removePadProbes(probes) }
}
// Hold-B-to-exit: with events consumed, the pad can't reach the Switch — a 1.2 s hold ends the
// test instead (touch still works). This half only ANSWERS the hold once it is long enough; the
@@ -351,7 +244,7 @@ private fun ControllersBody(
.padding(contentPadding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
heading()
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
@@ -825,6 +718,16 @@ private fun PadRow(info: PadInfo, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only when a correction is actually in force: on a pad Android has a key layout for
// there is nothing to say, and a line that says "normal" on every device teaches
// nobody anything. Named rather than merely flagged, so a field report can quote it.
padButtonsNote(info.buttons)?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (info.canRumble) {
OutlinedButton(onClick = { info.dev?.let(::testRumble) }) { Text("Test rumble") }
} else {
@@ -920,6 +823,12 @@ internal data class PadInfo(
val controllerNumber: Int,
val resolvedPref: Int,
val canRumble: Boolean,
/**
* The report order this pad's buttons were resolved to ([Gamepad.padButtons]). Defaults to
* the pad Android already knows, which is what a screenshot scene wants and what the note
* under the card stays silent about.
*/
val buttons: Gamepad.PadButtons = Gamepad.PadButtons.NATIVE,
val dev: InputDevice? = null,
)
@@ -929,6 +838,7 @@ internal fun padInfoOf(dev: InputDevice): PadInfo = PadInfo(
forwarded = isForwarded(dev),
controllerNumber = dev.controllerNumber,
resolvedPref = Gamepad.prefFor(dev),
buttons = Gamepad.padMap(dev).buttons, // via padMap so the list refresh reuses the cache
canRumble = deviceHasVibrator(dev),
dev = dev,
)
@@ -942,7 +852,8 @@ private fun deviceHasVibrator(dev: InputDevice): Boolean =
dev.vibrator.hasVibrator()
}
private fun testRumble(dev: InputDevice) {
/** A short pulse on the pad's own motor. Also the console's `PadAction::Rumble`. */
internal fun testRumble(dev: InputDevice) {
runCatching {
if (Build.VERSION.SDK_INT >= 31) {
val vm = dev.vibratorManager
@@ -958,6 +869,20 @@ private fun testRumble(dev: InputDevice) {
}
/** Identity line: VID:PID + the source classes Android assigned. */
/**
* What to say about a pad whose buttons had to be resolved from their scancodes because Android
* has no key layout for it null for a pad it does know, which needs no explanation.
*/
private fun padButtonsNote(buttons: Gamepad.PadButtons): String? = when (buttons) {
Gamepad.PadButtons.NATIVE -> null
Gamepad.PadButtons.GENERIC_SONY ->
"Android has no button layout for this controller — read as a PlayStation pad"
Gamepad.PadButtons.GENERIC_XBOX ->
"Android has no button layout for this controller — read as an Xbox pad"
Gamepad.PadButtons.SONY_MODERN ->
"Android has no button layout for this controller — face buttons corrected"
}
private fun deviceDetail(dev: InputDevice): String =
"%04X:%04X · %s".format(dev.vendorId, dev.productId, sourcesLabel(dev.sources))
@@ -1,631 +0,0 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.toggleableState
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.input.KeyboardType
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
private const val KB_ROWS = 5
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
/**
* A non-text row of the EDIT form a switch or a stepped choice, driven like a settings row rather
* than opening the keyboard. Add-host mode has none: they all edit properties a host only has once
* it is saved.
*/
private class ExtraRow(
val label: String,
val value: String,
/** Non-null = draw a [ConsoleSwitch] instead of the value text. */
val toggled: Boolean?,
val adjust: (Int) -> Unit,
val activate: () -> Unit,
)
@Composable
fun GamepadAddHostScreen(
onAdd: (name: String, address: String, port: Int) -> Unit,
onDismiss: () -> Unit,
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
editHost: KnownHost? = null,
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
/**
* The profile catalog, for the edit form's binding row. Empty (the default) simply omits that
* row which is also what a device with no profiles yet gets.
*/
profiles: List<StreamProfile> = emptyList(),
) {
val ink = LocalGamepadInk.current
val context = LocalContext.current
val isTv = remember { isTvDevice(context) }
val isEdit = editHost != null
val title = if (isEdit) "Edit Host" else "Add Host"
val actionLabel = if (isEdit) "Save" else "Add Host"
var name by remember { mutableStateOf(editHost?.name ?: "") }
var address by remember { mutableStateOf(editHost?.address ?: "") }
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
// The two host properties the console could not reach at all until now. `copy` preserved them,
// so nothing was ever LOST — but a couch-only user (a TV box has no touch interface to fall
// back to) could never decide either one, which the touch edit sheet has always offered.
var clipboard by remember(editHost) { mutableStateOf(editHost?.clipboardSync ?: true) }
// Filtered through the live catalog, so a binding to a since-deleted profile reads as unset
// rather than as a name nothing can resolve — the same guard the touch sheet applies.
var boundId by remember(editHost, profiles) {
mutableStateOf(editHost?.profileId?.takeIf { id -> profiles.any { it.id == id } })
}
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
fun commit() {
if (isEdit && editHost != null && onSave != null) {
onSave(
editHost.copy(
name = name.trim().ifEmpty { editHost.address },
address = address.trim(),
port = port.toIntOrNull() ?: editHost.port,
mac = KnownHostStore.parseMacs(mac),
clipboardSync = clipboard,
profileId = boundId,
),
)
} else {
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
}
}
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
// where the phone's own soft keyboard needs a touch a pad can't provide.
if (isTv) {
TvAddHostForm(
title = title, actionLabel = actionLabel,
name = name, onName = { name = it },
address = address, onAddress = { address = it },
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
mac = if (isEdit) mac else null, onMac = { mac = it },
canAdd = canAdd,
onAdd = { commit() },
onDismiss = onDismiss,
)
return
}
var focus by remember { mutableIntStateOf(1) } // start on Address
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
var kbRow by remember { mutableIntStateOf(1) }
var kbCol by remember { mutableIntStateOf(0) }
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val fields = buildList {
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
add(Field("address", "Address", address, "IP or hostname"))
add(Field("port", "Port", port, "9777"))
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
}
// The switch/choice rows, between the text fields and the action. Only in EDIT mode: both edit
// properties a host only has once it has been saved.
val extras = buildList {
if (isEdit) {
add(
ExtraRow(
label = "Shared clipboard",
value = if (clipboard) "On" else "Off",
toggled = clipboard,
// Directional = state-targeted, so holding a direction can't oscillate — the
// same rule the settings toggles and the pin picker use.
adjust = { d -> clipboard = d > 0 },
activate = { clipboard = !clipboard },
),
)
if (profiles.isNotEmpty()) {
// "Default settings" is the absence of a binding, not a profile, so it leads the
// ring as a null rather than being faked as an entry in the catalog.
val options = listOf<StreamProfile?>(null) + profiles
val idx = options.indexOfFirst { it?.id == boundId }.coerceAtLeast(0)
fun stepTo(delta: Int) {
val n = ((idx + delta) % options.size + options.size) % options.size
boundId = options[n]?.id
}
add(
ExtraRow(
label = "Profile",
value = options[idx]?.name ?: "Default settings",
toggled = null,
adjust = { d -> stepTo(d) },
activate = { stepTo(1) },
),
)
}
}
}
val actionIndex = fields.size + extras.size // the Save/Add action sits after everything
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
fun closeKeyboard() { editing = null }
fun editField(id: String, transform: (String) -> String) {
when (id) {
"name" -> name = transform(name)
"address" -> address = transform(address)
"port" -> port = transform(port).take(5)
"mac" -> mac = transform(mac)
}
}
fun allowed(id: String, c: Char): Boolean = when (id) {
"port" -> c.isDigit()
"address" -> c != ' '
else -> true
}
/** The focused row's extra, or null when the cursor is on a text field or the action. */
fun focusedExtra(): ExtraRow? = extras.getOrNull(focus - fields.size)
fun activateField() {
when {
focus == actionIndex -> if (canAdd) commit() else { focus = 1; openKeyboard("address") }
focus < fields.size -> openKeyboard(fields[focus].id)
else -> focusedExtra()?.activate()
}
}
fun pressKey() {
val id = editing ?: return
if (kbRow < KB_ACTIONS_ROW) {
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
if (allowed(id, c)) editField(id) { it + c }
} else when (kbCol) {
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
1 -> editField(id) { it.dropLast(1) }
else -> closeKeyboard()
}
}
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
if (editing == null) {
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actionIndex) focus++
// Left/right step the switch and the profile ring, exactly as they step a
// settings row. On a text field or the action they still do nothing — there is
// no value there to walk.
NavDir.LEFT -> focusedExtra()?.adjust(-1)
NavDir.RIGHT -> focusedExtra()?.adjust(1)
}
} else {
when (dir) {
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
NavDir.LEFT -> if (kbCol > 0) kbCol--
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
}
}
},
onActivate = { if (editing == null) activateField() else pressKey() },
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
onSecondary = { if (editing != null) closeKeyboard() },
)
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
val typeHints = listOf(
PadGlyph.hint('A', "Type") { pressKey() },
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
PadGlyph.hint('B', "Done") { closeKeyboard() },
)
val sideBySide = landscape && editing != null
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
if (sideBySide) {
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
Row(
Modifier.fillMaxSize().consoleSafeArea().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(
Modifier.weight(1f).fillMaxHeight().widthIn(max = 620.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
extras.forEachIndexed { i, e -> ExtraRowView(e, focused = false) { onFieldClick(fields.size + i) } }
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
}
Column(
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
}
}
} else {
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
Column(Modifier.fillMaxSize().consoleSafeArea().padding(horizontal = ConsoleEdgeInset)) {
Column(
// Same 620 dp cap as the settings rows: a field stretched across a wide
// landscape phone is a ribbon, not an input.
Modifier.weight(1f).widthIn(max = 620.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
if (editing == null && !landscape) {
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
)
}
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
extras.forEachIndexed { i, e ->
ExtraRowView(e, focused = focus == fields.size + i && editing == null) {
onFieldClick(fields.size + i)
}
}
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
}
if (editing != null) {
Spacer(Modifier.height(8.dp))
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
}
}
}
}
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
Box(
Modifier.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
if (editing != null) {
typeHints
} else {
listOf(
PadGlyph.hint('A', "Select") { activateField() },
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
)
},
hazeState = hazeState,
)
}
}
}
/**
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
* custom keyboard or input probes the native focus engine moves between fields and the Add button,
* and focusing a field pops the OS keyboard. B backs out.
*/
@Composable
private fun TvAddHostForm(
title: String,
actionLabel: String,
name: String,
onName: (String) -> Unit,
address: String,
onAddress: (String) -> Unit,
port: String,
onPort: (String) -> Unit,
mac: String?, // non-null only in edit mode
onMac: (String) -> Unit,
canAdd: Boolean,
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
GamepadFormBackground(Modifier.fillMaxSize())
Column(
Modifier
.fillMaxSize()
.consoleSafeArea()
.padding(horizontal = 56.dp, vertical = 36.dp)
.widthIn(max = 720.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
)
OutlinedTextField(
value = name, onValueChange = onName, singleLine = true,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
)
OutlinedTextField(
value = address, onValueChange = onAddress, singleLine = true,
label = { Text("Address") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = port, onValueChange = onPort, singleLine = true,
label = { Text("Port") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
if (mac != null) {
OutlinedTextField(
value = mac, onValueChange = onMac, singleLine = true,
label = { Text("Wake-on-LAN MAC") },
placeholder = { Text("auto-filled when the host is seen") },
modifier = Modifier.fillMaxWidth(),
)
}
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
Text(actionLabel)
}
}
}
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
}
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused || editing, editing = editing)
// The caret keeps its slot and only fades, like the settings rows' chevrons. Appending it on
// `editing` shoved the whole value left the instant the keyboard opened — the same
// layout-moves-under-focus bug the settings detail line had, one screen over.
val caretAlpha by animateFloatAsState(
if (editing) 1f else 0f,
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "caret",
)
Row(
modifier = Modifier
.fillMaxWidth()
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = ink.fg)
Spacer(Modifier.weight(1f))
Text(
f.value.ifEmpty { f.placeholder },
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = if (f.value.isEmpty()) ink.fg(0.35f) else ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(" |", color = ink.accent, modifier = Modifier.graphicsLayer { alpha = caretAlpha })
}
}
/**
* A switch or stepped-choice row of the edit form. Deliberately the settings screen's row in
* miniature same glass, same end-aligned value slot, same `ConsoleSwitch` because it IS a
* settings row: it edits a stored property with left/right, and a user who has met one has met
* both.
*/
@Composable
private fun ExtraRowView(row: ExtraRow, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
Row(
modifier = Modifier
.fillMaxWidth()
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.semantics(mergeDescendants = true) {
role = if (row.toggled != null) Role.Switch else Role.Button
contentDescription = "${row.label}, ${row.value}"
row.toggled?.let {
toggleableState = if (it) ToggleableState.On else ToggleableState.Off
}
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
if (row.toggled != null) {
ConsoleSwitch(on = row.toggled, focused = focused)
} else {
Text(
row.value,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(if (focused) 1f else 0.6f),
textAlign = TextAlign.End,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
val labelColor by animateColorAsState(
if (enabled) ink.accent else ink.fg(0.35f),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "addLabel",
)
Box(
modifier = Modifier
.fillMaxWidth()
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = labelColor,
)
}
}
@Composable
private fun KeyboardGrid(
cursorRow: Int,
cursorCol: Int,
compact: Boolean,
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
onKey: (Int, Int) -> Unit,
) {
val ink = LocalGamepadInk.current
val shape = ConsoleShape.Keyboard
val gap = if (compact) 5.dp else 7.dp
Column(
Modifier
.fillMaxWidth()
.widthIn(max = 640.dp)
.clip(shape)
// Palette glass, lifted a touch above a row's: the keyboard is a slab the keys sit on,
// and a hardcoded white wash was the one surface a pale palette couldn't recolour.
.background(ink.glass.copy(alpha = (ink.glass.alpha * 1.5f).coerceAtMost(1f)))
.border(1.dp, ink.fg(0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
) {
KB_CHAR_ROWS.forEachIndexed { r, chars ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
chars.forEachIndexed { c, ch ->
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
Keycap("", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
}
}
}
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
// Fast tweens: the keyboard cursor hops many keys per second under hold-to-repeat, so the
// trailing key must have faded before the cursor is two keys away — quick, but no longer a snap.
val bg by animateColorAsState(
if (focused) ink.accent else ink.glass,
tween(90),
label = "keyBg",
)
// `onAccent`, not black: a pale palette's accent can be light enough that black-on-it is the
// unreadable combination, and the palette already resolved which way that goes.
val fg by animateColorAsState(if (focused) ink.onAccent else ink.fg, tween(90), label = "keyFg")
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(ConsoleShape.Keycap)
.background(bg)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = fg,
textAlign = TextAlign.Center,
)
}
}
@@ -1,483 +0,0 @@
package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
/** One dialog action button. */
class DialogAction(
val label: String,
val primary: Boolean = false,
val enabled: Boolean = true,
val onClick: () -> Unit,
)
/**
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
* up, via ConnectScreen's `navActive`). B [onDismiss].
*/
@Composable
fun GamepadDialog(
title: String,
onDismiss: () -> Unit,
actions: List<DialogAction>,
body: @Composable ColumnScope.() -> Unit,
) {
val ink = LocalGamepadInk.current
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
else -> {}
}
},
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
)
// Cap the card to most of the screen and let body + BUTTONS scroll together — in a short
// landscape window a 5-action stack (host options) exceeds the card even with an empty body, and
// a pinned actions column can only compress/clip its last button. Only the title stays pinned;
// the focused button pulls itself into view (see DialogButton), so D-pad navigation always shows
// the current action even when the stack scrolls.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
ConsoleModal {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.consoleCard()
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
body()
Spacer(Modifier.size(4.dp))
actions.forEachIndexed { i, a ->
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val scale by animateFloatAsState(
if (focused) 1.02f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMediumLow),
label = "btnScale",
)
// The action stack lives inside the dialog's scroll region: when D-pad focus moves to a button
// that's scrolled out of a short window, pull it into view (no-op when already visible).
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
val focus by animateFloatAsState(
if (focused) 1f else 0f,
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnFocus",
)
// Focus sweeps up/down the stack — cross-fade the fills so it glides instead of snapping.
val bg by animateColorAsState(
when {
focused -> ink.accent
primary -> ink.accent(0.20f)
else -> ink.glass
},
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnBg",
)
val fg by animateColorAsState(
when {
!enabled -> ink.fg(0.35f)
// On the accent, not on the field — a pale palette's accent decides this, not the ink.
focused -> ink.onAccent
primary -> ink.accent
else -> ink.fg(0.85f)
},
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnFg",
)
val borderColor by animateColorAsState(
ink.fg(if (focused) 0.3f else 0.08f),
ConsoleMotion.ease(ConsoleMotion.FOCUS_MS),
label = "btnBorder",
)
Box(
modifier = Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.consoleGlass(ConsoleShape.Row, ConsoleFocusVisuals(scale, bg, borderColor, focus))
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 20.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
}
}
/** Body text helper — a dimmed paragraph. */
@Composable
private fun DialogText(text: String) {
val ink = LocalGamepadInk.current
Text(text, style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f))
}
/**
* Console host options for a saved tile Wake (offered only when offline + a MAC is known), Copy
* link, Edit, Forget. Reached by pressing Up on a focused saved host in the carousel; the console
* counterpart of the touch host card's overflow menu.
*/
@Composable
fun GamepadHostOptionsDialog(
hostName: String,
canWake: Boolean,
onWake: () -> Unit,
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
onEdit: () -> Unit,
onForget: () -> Unit,
/**
* Copy this tile's `punktfunk://` link. Offered on a pinned tile too — unlike the host's other
* actions it says nothing about the host, it hands out the shortcut this very tile already is
* (profile included), which is exactly what a pin is for.
*/
onCopyLink: () -> Unit,
onDismiss: () -> Unit,
onSpeedTest: (() -> Unit)? = null,
/**
* Non-null when this is a PINNED host+profile tile, whose only action is to unpin. A pin is a
* shortcut, not a second host offering the host's destructive actions on it would blur
* exactly that, and the touch grid withholds them for the same reason.
*/
onUnpin: (() -> Unit)? = null,
profileName: String? = null,
) {
GamepadDialog(
title = if (profileName != null) "$hostName · $profileName" else hostName,
onDismiss = onDismiss,
actions = buildList {
if (onUnpin != null) {
add(DialogAction("Unpin card", primary = true, onClick = onUnpin))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Cancel", onClick = onDismiss))
return@buildList
}
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake))
if (onSpeedTest != null) add(DialogAction("Network speed test", onClick = onSpeedTest))
add(DialogAction("Copy link", onClick = onCopyLink))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss))
},
) {
DialogText(
if (onUnpin != null) {
"This card is a shortcut to this host with one profile. Unpinning it changes " +
"nothing about the host or the profile."
} else {
"Manage this saved host."
},
)
}
}
/**
* The pin-to-hosts picker the settings screen's Profiles section opens the Android mirror of the
* desktop console's PinHostsScreen (design §5.2a): one toggle row per SAVED host, D-pad up/down
* moves, A flips the focused pin, left/right unpins/pins (the settings-toggle semantics), B closes.
* A toggle is presentation only: it edits the host's pinned cards through the same store write the
* carousel's unpin uses, never the profile itself and never the host's default binding.
*
* Pin state is read live from [pinned] (backed by the host records), so what a switch shows is
* always what the store holds the row can't disagree with the carousel it feeds.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun GamepadPinHostsDialog(
profileName: String,
hosts: List<KnownHost>,
pinned: (KnownHost) -> Boolean,
onToggle: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
val ink = LocalGamepadInk.current
// 0..hosts.lastIndex = host rows, hosts.size = the Done button (with no hosts, index 0 IS
// Done, so it starts focused).
var focus by remember { mutableIntStateOf(0) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < hosts.size) focus++
// Directional = state-targeted (left → unpinned, right → pinned), so holding a
// direction can't oscillate; asking for the state it's already in is a no-op.
NavDir.LEFT -> hosts.getOrNull(focus)?.let { if (pinned(it)) onToggle(it) }
NavDir.RIGHT -> hosts.getOrNull(focus)?.let { if (!pinned(it)) onToggle(it) }
}
},
onActivate = {
val kh = hosts.getOrNull(focus)
if (kh != null) onToggle(kh) else onDismiss()
},
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
ConsoleModal {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.consoleCard()
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"Pin “$profileName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (hosts.isEmpty()) {
DialogText("No saved hosts yet — pair with a host first, then pin this profile to it.")
} else {
DialogText("A pinned profile appears as its own card on the host — one press connects with it.")
hosts.forEachIndexed { i, kh ->
PinHostRow(
label = kh.name,
on = pinned(kh),
focused = i == focus,
onClick = { onToggle(kh) },
)
}
}
Spacer(Modifier.size(4.dp))
DialogButton(
"Done",
focused = focus == hosts.size,
primary = true,
enabled = true,
onClick = onDismiss,
)
}
}
}
}
/** One host's pin toggle: name + a [ConsoleSwitch], with the shared console focus visuals. */
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PinHostRow(label: String, on: Boolean, focused: Boolean, onClick: () -> Unit) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = focused)
// Inside the dialog's scroll region, like DialogButton: a focused row scrolled out of a short
// landscape window pulls itself into view.
val intoView = remember { BringIntoViewRequester() }
LaunchedEffect(focused) { if (focused) intoView.bringIntoView() }
Row(
Modifier
.fillMaxWidth()
.bringIntoViewRequester(intoView)
.consoleGlass(ConsoleShape.Row, visuals)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.weight(1f))
ConsoleSwitch(on = on, focused = focused)
}
}
/**
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
* 09), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
* fingerprint to [onPaired]. No text keyboard needed a PIN is four digits.
*/
@Composable
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
val ink = LocalGamepadInk.current
val scope = rememberCoroutineScope()
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
val context = LocalContext.current
val name = remember(context) { deviceName(context) }
fun pair() {
val id = identity ?: return
pairing = true
err = null
val pin = digits.joinToString("")
scope.launch {
val fp = withContext(Dispatchers.IO) {
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
}
pairing = false
if (fp.isNotEmpty()) {
onPaired(fp)
} else {
// Cause-specific: wrong PIN vs not-armed vs unreachable.
err = ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())
}
}
}
BackHandler(onBack = { if (!pairing) onDismiss() })
GamepadNavEffect2D(
active = !pairing,
onDirection = { dir ->
when (dir) {
NavDir.LEFT -> if (slot > 0) slot--
NavDir.RIGHT -> if (slot < 4) slot++
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
}
},
onActivate = { if (slot == 4 && identity != null) pair() },
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
ConsoleModal {
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.consoleCard()
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = ink.fg)
Text(
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
style = MaterialTheme.typography.bodyMedium, color = ink.fg(0.7f), textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
}
err?.let { Text(it, color = ink.danger, style = MaterialTheme.typography.bodyMedium) }
DialogButton(
label = if (pairing) "Pairing…" else "Pair",
focused = slot == 4 && !pairing,
primary = true,
enabled = !pairing && identity != null,
onClick = { if (identity != null) pair() },
)
}
}
}
@Composable
private fun PinSlot(value: Int, focused: Boolean) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.size(54.dp, 66.dp).clip(shape)
.background(if (focused) ink.accent(0.20f) else ink.glass)
.border(if (focused) 2.dp else 1.dp, if (focused) ink.accent else ink.fg(0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(
value.toString(),
fontSize = 30.sp,
fontWeight = FontWeight.Bold,
color = ink.fg,
fontFamily = FontFamily.Monospace,
)
}
}
@@ -1,466 +0,0 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.security.KnownHost
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlinx.coroutines.launch
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
/**
* How far a fully off-centre card turns away from the viewer, in radians (~48°). Never rendered as
* a rotation see the projection note at the call site.
*/
private const val CARD_TURN_RAD = 0.838f
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
class HomeTile(
val id: String,
val title: String,
val subtitle: String,
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
val online: Boolean = false, // advertising on the LAN right now
val paired: Boolean = false, // pinned identity (shows a lock)
val connecting: Boolean = false,
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
/**
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
* its own shelf and every launch off it carries this profile.
*/
val pinnedProfileId: String? = null,
/**
* The profile a press will actually connect with the host's binding, or the pin's own
* profile. Rendered as a chip on the card rather than appended to the subtitle: on a PIN card
* the profile is the entire reason the card exists, and a card that only whispers it in grey
* body text can't say that. Matches the Apple client's tile.
*/
val profileName: String? = null,
/** The profile's `#RRGGBB` chip colour, if it set one. */
val profileAccent: Color? = null,
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple), pinned cards included — the fetch itself
// returns a clear "pair first" message if the host hasn't authorized this device for its
// management API.
val hasLibrary: Boolean get() = knownHost != null
}
/**
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
* / face buttons (MainActivity already maps a pad's Acenter, Bback, sticksD-pad) and by touch.
*/
@Composable
fun GamepadHome(
tiles: List<HomeTile>,
libraryEnabled: Boolean,
controllerName: String?,
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
// can be driven instead.
navActive: Boolean,
onActivate: (HomeTile) -> Unit,
onOpenLibrary: (HomeTile) -> Unit,
onOpenSettings: () -> Unit,
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
onOptions: (HomeTile) -> Unit = {},
) {
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
// sits the same distance from the left and the bottom).
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val pagerState = rememberPagerState(pageCount = { tiles.size })
val scope = rememberCoroutineScope()
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
// mid-animation (which is what let a flick overshoot by two).
var navTarget by remember { mutableStateOf(0) }
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
val current = tiles.getOrNull(navTarget)
// Bumped on every confirm — the centred card dips under the press and springs back, so A reads
// as a button being pushed rather than as a screen simply changing.
var pressToken by remember { mutableIntStateOf(0) }
val press = remember { Animatable(1f) }
LaunchedEffect(pressToken) {
if (pressToken == 0) return@LaunchedEffect
press.animateTo(0.97f, ConsoleMotion.ease(70))
press.animateTo(1f, spring(dampingRatio = 0.45f, stiffness = Spring.StiffnessMedium))
}
GamepadNavEffect(
active = navActive && tiles.isNotEmpty(),
onMove = { dir ->
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
if (target != navTarget) {
navTarget = target
scope.launch { pagerState.animateScrollToPage(target) }
}
},
// A / D-pad-center → Connect
onActivate = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) },
onSecondary = { // Y (gamepad) → Library
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
},
onTertiary = onOpenSettings, // X (gamepad) → Settings
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
onUp = onOpenSettings,
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
)
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
// way. Each hint is also TAPPABLE (touch hatch).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
val connectAction: () -> Unit = { pressToken++; tiles.getOrNull(navTarget)?.let(onActivate) }
val optionsAction: () -> Unit = { current?.let(onOptions) }
val arrowTint = PadGlyph.Arrow
val hints = buildList {
if (padIsGamepad) {
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
})
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
} else {
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
}
}
val hazeState = remember { HazeState() }
Box(Modifier.fillMaxSize()) {
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
// whatever scrolls under it.
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
// no longer push the cards below the true centre.
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
// The carousel deliberately IGNORES the safe area (first on-glass verdict): only the
// CENTRED card matters, and it sits mid-screen; the fanned neighbours running under
// the hole punch is ambience, while insetting the pager CLIPPED them at the cutout
// edge — cards visibly cut off is worse than cards behind a camera. The title and the
// legend keep their insets; they are content.
Box(Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(cardWidth),
contentPadding = PaddingValues(horizontal = sidePad),
pageSpacing = 22.dp,
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
) { page ->
val tile = tiles[page]
// Real distance-from-centered (page + fractional drag), so the pop tracks the
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
// Signed, because which SIDE a card fans to decides which edge it turns on.
val signed = (page - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val offset = signed.absoluteValue.coerceIn(0f, 1f)
GamepadHostTile(
tile = tile,
centred = offset < 0.5f,
modifier = Modifier
.graphicsLayer {
// The press dip applies to the CENTRED card only — it is the one
// the button acted on, and a whole carousel flinching would read
// as the screen moving rather than a card being pressed.
val s = lerp(1f, 0.86f, offset) * lerp(press.value, 1f, offset)
scaleX = s
scaleY = s
alpha = lerp(1f, 0.5f, offset)
}
.graphicsLayer {
// The neighbours TURN away, projected rather than rendered in 3D.
// `cos(angle)` as a horizontal squeeze IS the orthographic
// projection of a Y-axis rotation, and hinging it on the edge the
// card fans from is what carries the direction the rotation's sign
// would have. The Apple client arrived here the hard way (see
// GamepadCarousel.swift): a real `rotation3DEffect` renders the
// card through an offscreen pass and flashed as the strip settled.
// Affine transforms don't.
scaleX = cos(CARD_TURN_RAD * offset)
transformOrigin =
TransformOrigin(if (signed > 0f) 0f else 1f, 0.5f)
}
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
// (the cut-off edge). No-op below API 31; a soft blur above.
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.height(cardHeight)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (page == navTarget) {
pressToken++
onActivate(tile)
} else {
navTarget = page
scope.launch { pagerState.animateScrollToPage(page) }
}
},
)
}
}
}
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
// the shared ConsoleHeader so it lines up with every other screen's heading.
Row(
Modifier.align(Alignment.TopStart).fillMaxWidth().consoleSafeArea()
.padding(end = ConsoleEdgeInset),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// The TITLE has priority (unweighted, so it is measured at its full width first) and the
// chip takes what is left, ellipsizing its device name. The other way round — which is
// what a weighted header gave — a talkative controller name ("Xbox Wireless Controller")
// ate a 360 dp portrait phone's title down to "Selec…".
ConsoleHeader("Select a Host")
if (controllerName != null) {
ControllerStatusChip(controllerName, Modifier.weight(1f, fill = false))
}
}
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
// it ignores the system bars (the nav-bar inset made the bottom gap look oversized) but never
// the cutout — reverse-landscape parks the punch on this very corner.
Box(
Modifier
.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(hints, hazeState = hazeState)
}
}
}
/**
* One glass landscape console tile bigger and bolder than the touch grid's HostCard, and cut from
* the same [Modifier.consoleGlass] every console surface is, so a card and a settings row catch the
* light the same way. [centred] is the carousel's own focus: the tile the pad is pointing at, which
* earns the lift and the accent bloom.
*/
@Composable
private fun GamepadHostTile(tile: HomeTile, centred: Boolean, modifier: Modifier = Modifier) {
val ink = LocalGamepadInk.current
val visuals = animateConsoleFocus(active = centred)
// A SAVED host wears the palette's accent; a discovered one (or the Add tile) stays neutral
// glass, so "already yours" reads before you get to the label.
val fill = if (tile.filled) ink.accent(0.20f) else ink.glass
Column(
modifier = modifier
.fillMaxWidth()
// The carousel already drives its own scale; the glass must not fight it with a second.
.consoleGlass(
ConsoleShape.Tile,
ConsoleFocusVisuals(1f, fill, ink.fg(0.16f), visuals.focus),
// A DASHED edge on anything not yet saved — a host found on the network, and the
// Add tile. It is the touch grid's own convention and the Apple client's, and it
// says "not yours yet" before the subtitle has to.
dashed = !tile.filled,
)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
MonogramBadge(tile)
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (tile.paired) {
Icon(
Icons.Filled.Lock,
contentDescription = "Paired",
tint = ink.fg(0.7f),
modifier = Modifier.padding(end = 6.dp).size(15.dp),
)
}
if (tile.online) {
Box(
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0xFF3CD070)),
)
}
}
}
Spacer(Modifier.weight(1f))
Text(
tile.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = ink.fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (tile.profileName != null) {
ConsoleProfileChip(
name = tile.profileName,
accent = tile.profileAccent,
// On a PIN card the profile is why the card exists; on a bound host's own card it
// is a note about what a press will do. Same chip, two weights.
prominent = tile.pinnedProfileId != null,
modifier = Modifier.padding(top = 5.dp),
)
}
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = ink.fg(0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
}
/**
* The profile a card connects with, worn as a tinted capsule. The console counterpart of the touch
* grid's own chip (`HostComponents.kt`) same shape and the same quiet/prominent split, but inked
* from the console palette rather than `MaterialTheme`, since it sits on the aurora.
*
* A profile that set no accent falls back to the palette's, not to the touch theme's primary: on a
* moss or copper field the brand violet would be the one foreign colour on the card.
*/
@Composable
private fun ConsoleProfileChip(
name: String,
accent: Color?,
prominent: Boolean,
modifier: Modifier = Modifier,
) {
val ink = LocalGamepadInk.current
val tint = accent ?: ink.accent
Row(
modifier = modifier
.clip(ConsoleShape.Pill)
.background(tint.copy(alpha = if (prominent) 0.24f else 0.12f))
.padding(horizontal = 9.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(tint))
Spacer(Modifier.width(6.dp))
Text(
name,
style = if (prominent) {
MaterialTheme.typography.labelLarge
} else {
MaterialTheme.typography.labelMedium
},
fontWeight = if (prominent) FontWeight.Bold else FontWeight.SemiBold,
color = tint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun MonogramBadge(tile: HomeTile) {
val ink = LocalGamepadInk.current
val shape = RoundedCornerShape(15.dp)
// Lit from the top like every other console surface — and the unsaved badge takes the palette's
// own accent at low opacity rather than the brand violet, which on a copper or moss field was
// the one square of the wrong hue on the screen.
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(ink.accent.copy(alpha = 0.92f), ink.accent))
} else {
Brush.verticalGradient(listOf(ink.accent(0.20f), ink.accent(0.14f)))
}
Box(
modifier = Modifier.size(52.dp).clip(shape).background(fill),
contentAlignment = Alignment.Center,
) {
when {
tile.connecting -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = ink.fg,
)
tile.isAdd -> Icon(
Icons.Filled.Add,
contentDescription = null,
tint = if (tile.filled) ink.fg else ink.accent,
)
else -> Text(
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (tile.filled) ink.fg else ink.accent,
)
}
}
}
@@ -11,6 +11,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
import kotlin.math.abs
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
@@ -82,8 +83,9 @@ fun GamepadNavEffect(
val currentOnOptions by rememberUpdatedState(onOptions)
DisposableEffect(active) {
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
// One entry on the MainActivity probe stack (see GamepadNavEffect2D), removed by identity on
// dispose — a cross-fading-out screen must take only its OWN claim, never the incoming
// screen's, and never the console shell's underneath.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -95,7 +97,7 @@ fun GamepadNavEffect(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
@@ -113,13 +115,10 @@ fun GamepadNavEffect(
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
probes?.let { activity.removePadProbes(it) }
state.reset()
}
}
@@ -186,9 +185,11 @@ fun GamepadNavEffect2D(
val currentOnShoulder by rememberUpdatedState(onShoulder)
DisposableEffect(active) {
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
// One entry on the MainActivity probe stack, removed by identity on dispose — during a
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
// teardown must not null out the incoming screen's just-installed probes.
// teardown must take only its own claim. On the console this effect sits OVER the Skia
// shell's probes: pushing (not overwriting) is what lets the shell's pad input resurface
// the moment this screen pops, instead of dying with a nulled slot.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
@@ -202,7 +203,7 @@ fun GamepadNavEffect2D(
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
when (Gamepad.padKeyCode(ev)) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
@@ -220,13 +221,10 @@ fun GamepadNavEffect2D(
else -> false // B → MainActivity (remapped to BACK → BackHandler)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
val probes = if (active) MainActivity.PadProbes(keyProbe, motionProbe) else null
probes?.let { activity.pushPadProbes(it) }
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
probes?.let { activity.removePadProbes(it) }
state.reset()
}
}
File diff suppressed because it is too large Load Diff
@@ -1,100 +0,0 @@
package io.unom.punktfunk
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
/**
* The console home's tiles, in carousel order: every saved host with its pinned host+profile cards
* immediately behind it, then the hosts seen on the network but not yet saved, then Add Host.
*
* Pure, and deliberately not a composable. The half of this that can be WRONG is the ordering and
* what a tile claims a pin drifting away from the host it belongs to, a discovered host offered a
* second time next to the saved record it already is, a chip naming a profile the press won't
* actually use. None of that needs a display to be checked, and `HomeTilesTest` checks it without
* one; the console home itself needs the live JNI core to compose at all.
*
* [isOnline] and [pinsFor] arrive as lambdas rather than as the discovery lists and the profile
* store behind them: "online" means advertising on mDNS OR answering a QUIC probe (the routed
* Tailscale/VPN case), which is a rule belonging to the screen that does the probing, not to a list
* builder.
*/
internal fun buildHomeTiles(
savedHosts: List<KnownHost>,
/** The live catalog — resolves each host's binding into the name and colour its chip wears. */
profiles: List<StreamProfile>,
pinsFor: (KnownHost) -> List<StreamProfile>,
/** Already de-duped against [savedHosts] by the caller: a saved host is not also "discovered". */
discoveredUnsaved: List<DiscoveredHost>,
isOnline: (KnownHost) -> Boolean,
/**
* Dial a saved host. The second argument is `connect`'s one-off profile reference: null on a
* host's own tile (follow whatever the host is bound to), the pinned profile's id on a pin tile.
*/
onConnect: (KnownHost, String?) -> Unit,
onConnectDiscovered: (DiscoveredHost) -> Unit,
onAddHost: () -> Unit,
): List<HomeTile> = buildList {
savedHosts.forEach { kh ->
val bound = kh.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
add(
HomeTile(
id = "saved-${kh.id}",
title = kh.name,
subtitle = "${kh.address}:${kh.port}",
filled = true,
online = isOnline(kh),
paired = kh.paired,
knownHost = kh,
// The binding is what a press will actually do, so the tile says so — the console
// can't edit profiles, but it must never lie about which one it uses. It rides in
// the card's own chip now rather than as a "· Name" tail on the address, which is
// where it read as an afterthought.
profileName = bound?.name,
profileAccent = accentColor(bound?.accent),
activate = { onConnect(kh, null) },
),
)
// Pinned host+profile combinations, right after their host: one focus-and-press each,
// which is the affordance a controller surface does well (menus are not).
pinsFor(kh).forEach { p ->
add(
HomeTile(
id = "pin-${kh.id}-${p.id}",
title = kh.name,
// The address, like every other card — the PROFILE is what makes this card
// different, and it now says so in the chip instead of standing in for the
// subtitle, which left a pin card unable to say where it pointed.
subtitle = "${kh.address}:${kh.port}",
filled = true,
online = isOnline(kh),
paired = kh.paired,
knownHost = kh,
pinnedProfileId = p.id,
profileName = p.name,
profileAccent = accentColor(p.accent),
activate = { onConnect(kh, p.id) },
),
)
}
}
discoveredUnsaved.forEach { dh ->
add(
HomeTile(
id = "disc-${dh.host}:${dh.port}",
title = dh.name,
subtitle = "${dh.host}:${dh.port}",
online = true,
activate = { onConnectDiscovered(dh) },
),
)
}
add(
HomeTile(
id = "add",
title = "Add Host",
subtitle = "Register a host by address",
isAdd = true,
activate = onAddHost,
),
)
}
@@ -41,6 +41,16 @@ suspend fun connectToHost(
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
val gamepadPref = Gamepad.resolvePref(settings.gamepad)
// The requested audio format as the two Hello fields — `0`/`0` when the user chose Standard,
// which is what keeps the lossless capability bit OFF (see `audioFormatWire`).
//
// Sent at every channel count, including surround. This used to be clamped to Opus on 5.1/7.1
// because a lossless surround frame did not fit one QUIC datagram, but the frame ladder is
// channel-aware: a 5.1 session simply negotiates a shorter frame (and pays for it in packet
// rate) and 96/24 5.1 fits nothing and is declined. That is the host's decision to make with the
// connection's real datagram size in hand, not one to pre-empt from here with an MTU this side
// never measured.
val (audioRateHz, audioBits) = settings.audioFormatWire()
return withContext(Dispatchers.IO) {
// Transport-level half of "Low-latency mode (experimental)" (DSCP marking on the media
// sockets) — must be applied before connect, since sockets are tagged at creation.
@@ -75,6 +85,11 @@ suspend fun connectToHost(
hdrEnabled, multiSlice,
frameParts,
settings.audioChannels,
// The audio format this session asks for. Only ever a request: the host's own gate
// may resolve it back to Opus, and the native side downgrades it first if AAudio on
// this device will not open the rate — a rate the wire has committed to cannot be
// rescued afterwards, so the fallback has to happen before the Hello.
audioRateHz, audioBits,
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
// the soft codec preference (user choice, or the Automatic AV1 rule above) — the
// host resolves the emitted codec from both.
@@ -0,0 +1,52 @@
package io.unom.punktfunk
import android.content.Context
// Where the player was in a host's library, so the round trip back from a stream doesn't lose it.
// The Android mirror of the Apple client's `LibraryScrollMemory`.
//
// Leaving a stream re-composes the library screen from scratch — new `remember`s, a new
// `LazyGridState`, a new `PagerState` — so a library of any size came back at the top every time.
// For the loop this screen exists to serve (browse → play → quit → browse), that means
// re-scrolling to the same place on every lap.
//
// The position is remembered as the ID OF THE TITLE the player last opened, not as a scroll offset
// or an index. An offset is meaningless across the things that legitimately change between visits —
// a rotation, a window resize, a foldable unfolding, a host that gained or lost titles, or the
// running-first ordering this screen now applies. A title id survives all of them, and the grid
// turns it back into a position at whatever the current layout is.
/**
* Per-host "last title opened", in `SharedPreferences`.
*
* Small, non-sensitive and worth surviving a process death the app being killed in the background
* while a stream is up is exactly when this is most useful so preferences rather than an in-memory
* cache. One key per host record id, namespaced so nothing else can collide with it.
*/
object LibraryPosition {
private const val PREFS = "punktfunk_library_position"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/** The title last opened from this host's library, if any is remembered. */
fun last(context: Context, hostId: String): String? =
prefs(context).getString(hostId, null)
/**
* Remember a title as this host's position. Called when one is LAUNCHED, which is the only
* moment the player is definitely leaving the grid for it remembering on mere focus would
* make a scroll past a tile into a decision.
*/
fun remember(context: Context, hostId: String, gameId: String) {
prefs(context).edit().putString(hostId, gameId).apply()
}
/**
* Forget a host's position part of removing the host, so a forgotten host leaves no trace of
* what somebody was playing behind on the device.
*/
fun forget(context: Context, hostId: String) {
prefs(context).edit().remove(hostId).apply()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
package io.unom.punktfunk
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
// The clipboard half of "Copy link" (design/client-deep-links.md §4/§5), shared by every surface
// that hands a `punktfunk://` URL out: a host card, a pinned card, and a library title. The URL
// each one builds is its own business; whether the platform TOOK it, and what to say about that,
// is the same answer three times over — and getting it wrong in one place is how a menu item ends
// up silently doing nothing on exactly one screen.
/** Put a `punktfunk://` URL on the clipboard. False = no clipboard service, or it refused. */
internal fun putLinkOnClipboard(context: Context, url: String): Boolean {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return false
return runCatching {
clipboard.setPrimaryClip(ClipData.newPlainText("Punktfunk link", url))
}.isSuccess
}
/**
* What to tell the user about a copy, or null for "say nothing".
*
* Android 13 draws its own clipboard confirmation, and stacking a second one on top of it is the
* platform's own documented anti-pattern. Below it nothing visible happens at all unless we say
* so a silent menu item reads as a broken one.
*/
internal fun linkCopyMessage(copied: Boolean): String? = when {
copied && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> null
copied -> "Link copied."
else -> "Couldn't copy the link to the clipboard."
}
@@ -109,12 +109,29 @@ class MainActivity : ComponentActivity() {
var gamepadRouter: GamepadRouter? = null
/**
* Input observers for the Controllers debug screen (set while it is shown, like [streamHandle]).
* Called for every key/motion event while not streaming; a `true` return consumes the event
* the screen's "test inputs" mode uses that to keep pad input from also driving focus navigation.
* One screen's claim on the pad while not streaming: its key/motion observers, consulted for
* every event before the focus-navigation fallbacks below; a `true` return consumes the event.
* Holders are the Skia console shell, [GamepadNavEffect2D] on the Compose screens the console
* opens over itself, and the Controllers screen's input test.
*/
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
class PadProbes(val key: (KeyEvent) -> Boolean, val motion: (MotionEvent) -> Boolean)
/**
* The pad-probe claims, a STACK only the top entry sees events. A single last-writer-wins
* slot is how the console shell used to lose the pad for good: a screen composed over it
* (Controllers/Licenses) overwrote the slot, then nulled it on its way out, and the shell
* whose install effect had no reason to re-run never got it back. Pushing on install and
* removing BY IDENTITY on dispose survives every ordering Compose produces (cross-fades
* compose both screens at once, and dispose is not always LIFO): whatever leaves takes only
* its own entry, and whatever is left on top resumes seeing the pad.
*/
private val padProbes = mutableListOf<PadProbes>()
fun pushPadProbes(p: PadProbes) { padProbes += p }
fun removePadProbes(p: PadProbes) { padProbes.remove(p) }
private val padKeyProbe: ((KeyEvent) -> Boolean)? get() = padProbes.lastOrNull()?.key
private val padMotionProbe: ((MotionEvent) -> Boolean)? get() = padProbes.lastOrNull()?.motion
/**
* Physical-mouse forwarder for the active session (built/released by StreamScreen, like
@@ -599,7 +616,7 @@ class MainActivity : ComponentActivity() {
// no BUTTON_SELECT scancode delivers its Select: see [Gamepad.padButtonBit], which is
// why this asks it rather than `buttonBit`).
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
val bit = Gamepad.padButtonBit(event.keyCode, event.flags)
val bit = Gamepad.padButtonBit(Gamepad.padKeyCode(event), event.flags)
if (bit != 0) {
// The router forwards the bit on this device's own wire pad index and tracks held
// state per pad. The emergency-exit chord (Select + Start + L1 + R1) is handled
@@ -691,8 +708,10 @@ class MainActivity : ComponentActivity() {
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched.
when (event.keyCode) {
// already move focus on their own, so they fall through to super untouched. Read
// through [Gamepad.padKeyCode] so a pad Android has no key layout for reaches the
// menus on the right buttons too, not only the stream.
when (Gamepad.padKeyCode(event)) {
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
@@ -37,6 +37,12 @@ data class SettingsOverlay(
val hdrEnabled: Boolean? = null,
val compositor: Int? = null,
val audioChannels: Int? = null,
/**
* The requested audio format ([AUDIO_FORMAT_OPTIONS]'s stored value). Profileable because it
* is about how a HOST is streamed a wired desktop can afford lossless, a phone on a hotspot
* cannot rather than about this device's hardware.
*/
val audioFormat: String? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val touchMode: TouchMode? = null,
@@ -73,6 +79,7 @@ data class SettingsOverlay(
hdrEnabled = hdrEnabled ?: base.hdrEnabled,
compositor = compositor ?: base.compositor,
audioChannels = audioChannels ?: base.audioChannels,
audioFormat = audioFormat ?: base.audioFormat,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
touchMode = touchMode ?: base.touchMode,
@@ -110,6 +117,7 @@ data class SettingsOverlay(
hdrEnabled = if (after.hdrEnabled != before.hdrEnabled) after.hdrEnabled else hdrEnabled,
compositor = if (after.compositor != before.compositor) after.compositor else compositor,
audioChannels = if (after.audioChannels != before.audioChannels) after.audioChannels else audioChannels,
audioFormat = if (after.audioFormat != before.audioFormat) after.audioFormat else audioFormat,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
@@ -141,6 +149,7 @@ data class SettingsOverlay(
"hdr_enabled" -> copy(hdrEnabled = null)
"compositor" -> copy(compositor = null)
"audio_channels" -> copy(audioChannels = null)
"audio_format" -> copy(audioFormat = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"touch_mode" -> copy(touchMode = null)
@@ -167,6 +176,7 @@ data class SettingsOverlay(
if (hdrEnabled != null) add("hdr_enabled")
if (compositor != null) add("compositor")
if (audioChannels != null) add("audio_channels")
if (audioFormat != null) add("audio_format")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (touchMode != null) add("touch_mode")
@@ -201,6 +211,7 @@ data class SettingsOverlay(
hdrEnabled?.let { j.put("hdr_enabled", it) }
compositor?.let { j.put("compositor", it) }
audioChannels?.let { j.put("audio_channels", it) }
audioFormat?.let { j.put("audio_format", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", it) }
touchMode?.let { j.put("touch_mode", it.name) }
@@ -224,7 +235,7 @@ data class SettingsOverlay(
/** Keys this build models; everything else in a stored overlay is carried through. */
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "echo_cancel",
"hdr_enabled", "compositor", "audio_channels", "audio_format", "mic_enabled", "echo_cancel",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
@@ -241,6 +252,7 @@ data class SettingsOverlay(
hdrEnabled = j.optBooleanOrNull("hdr_enabled"),
compositor = j.optIntOrNull("compositor"),
audioChannels = j.optIntOrNull("audio_channels"),
audioFormat = j.optStringOrNull("audio_format"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
touchMode = j.optStringOrNull("touch_mode")
@@ -62,6 +62,21 @@ data class Settings(
/** Requested audio channel count: 2 (stereo), 6 (5.1) or 8 (7.1). The host clamps to what it
* can capture; the resolved count drives the decoder + AAudio layout. */
val audioChannels: Int = 2,
/**
* Requested audio format the cross-client `audio_format` key: [AUDIO_FORMAT_OPUS] (the
* default, and byte-for-byte the session every build before the lossless plane ran) or one of
* the lossless rows in [AUDIO_FORMAT_OPTIONS], which span both rate families.
*
* Off by default and deliberately: lossless takes 2.18.5 Mbps off the top of the link,
* OUTSIDE the ABR loop that manages the video budget, against the ~256 kbps Opus it replaces
* so a user has to pick it. Since 2026-08-17 this setting is the ONLY opt-in: the host's half
* (`PUNKTFUNK_AUDIO_HIRES`) defaults ON and is an opt-OUT (`=0`), so this choice is enough on
* any host that has not deliberately turned the plane off.
* A REQUEST, never a fact: the host runs its gate and may answer Opus anyway, and
* the native side downgrades the rate first if THIS device will not open it. What actually
* happened is on the stats HUD, and in logcat's `audio: plane codec= rate=` line.
*/
val audioFormat: String = AUDIO_FORMAT_OPUS,
/** Preferred video codec: `"auto"` (host decides), `"hevc"`, `"h264"`, or `"av1"`. A soft
* preference the host emits it when it can, else falls back. AMediaCodec decodes whichever
* the host resolves (AV1 is only advertised/offered when the device has a real AV1 decoder). */
@@ -295,6 +310,7 @@ class SettingsStore(context: Context) {
systemButtons = prefs.getString(K_SYSTEM_BUTTONS, "auto") ?: "auto",
guideGesture = prefs.getString(K_GUIDE_GESTURE, "auto") ?: "auto",
audioChannels = prefs.getInt(K_AUDIO_CH, 2),
audioFormat = prefs.getString(K_AUDIO_FORMAT, AUDIO_FORMAT_OPUS) ?: AUDIO_FORMAT_OPUS,
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
@@ -350,6 +366,7 @@ class SettingsStore(context: Context) {
.putString(K_SYSTEM_BUTTONS, s.systemButtons)
.putString(K_GUIDE_GESTURE, s.guideGesture)
.putInt(K_AUDIO_CH, s.audioChannels)
.putString(K_AUDIO_FORMAT, s.audioFormat)
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
@@ -387,6 +404,7 @@ class SettingsStore(context: Context) {
const val K_SYSTEM_BUTTONS = "system_buttons"
const val K_GUIDE_GESTURE = "guide_gesture"
const val K_AUDIO_CH = "audio_channels"
const val K_AUDIO_FORMAT = "audio_format"
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
@@ -691,6 +709,123 @@ val AUDIO_CHANNEL_OPTIONS = listOf(
8 to "7.1 Surround",
)
/** Opus 48 kHz — the default, and byte-for-byte the session every earlier build ran. */
const val AUDIO_FORMAT_OPUS = "opus"
/**
* Bit-exact PCM at 44.1 kHz / 24-bit (~2.1 Mbps). The CD family's base rate: what an ordinary
* Windows endpoint or a 44.1 kHz interface reports as its own engine rate, and the request that
* spares such a host a resample it would otherwise do on the way out.
*/
const val AUDIO_FORMAT_LOSSLESS_441 = "lossless441"
/**
* Bit-exact PCM at 48 kHz / 24-bit (~2.3 Mbps). The honest win even without a hi-res interface:
* no lossy stage at all, and no double resample on a host whose engine already runs at 48 kHz.
*/
const val AUDIO_FORMAT_LOSSLESS_48 = "lossless48"
/** Bit-exact PCM at 88.2 kHz / 24-bit (~4.2 Mbps) — 96 kHz's counterpart in the 44.1 family. */
const val AUDIO_FORMAT_LOSSLESS_882 = "lossless882"
/**
* Bit-exact PCM at 96 kHz / 24-bit (~4.6 Mbps), and only real if the host's capture endpoint
* genuinely runs at 96 kHz the host declines rather than upsampling to meet the request.
*/
const val AUDIO_FORMAT_LOSSLESS_96 = "lossless96"
/**
* Bit-exact PCM at 176.4 kHz / 24-bit **8.5 Mbps**, and the one row far more likely to be
* declined than granted. Three separate things have to go right: the host's bandwidth gate gives
* audio at most a quarter of the video budget, so the session needs ~34 Mbps of video before it
* will even consider it; a stereo frame only fits a QUIC datagram on the ladder's shortest rung
* (1 ms a thousand datagrams a second at ~1 069 B, so the first connection with a smaller
* datagram declines it), and a surround one fits no rung at all; and very few Android outputs will
* open the rate, which the native probe settles before the handshake. Offered because it is
* reachable, not because it is likely the HUD's `audio lossless ` line is what says which
* happened.
*/
const val AUDIO_FORMAT_LOSSLESS_1764 = "lossless1764"
/**
* (stored value, label) for the requested audio format the cross-client table, matching the
* Apple client's `AudioFormatChoice` raw values and the desktop `AUDIO_FORMATS` so a profile
* written on any of them is honoured on the others.
*
* **The stored values are shared VERBATIM and must never be renamed.** A profile carries the key
* through untouched, so a spelling that differs by one character fails in the worst possible way:
* the profile keeps "working" on the other client and silently inherits its global default
* instead. The naming rule is the kHz figure with the decimal point dropped `lossless48`,
* `lossless96`, and for the 44.1 family `lossless441` / `lossless882` / `lossless1764`.
*
* **Both rate families are here now.** They were not: every buffer figure in the shared jitter
* policy used to be `ms × perMs` with `perMs` an INTEGER number of samples per millisecond, which
* made 44 100 44.1 truncate to 44 a silent 2.3 % error in every target, every de-prime fuse
* and every reported buffer depth, and the whole reason the 44.1 family was deferred rather than
* refused (design/hi-res-audio.md §4.1). Core now multiplies before it divides, which is exact at
* every rate, so the deferral is lifted.
*
* A row being offered is not a promise it can be delivered: the host's gate, this device's own
* output, and the path MTU each get a veto, and the ones at the top of the list get vetoed often.
* What actually happened is on the HUD.
*
* Lossless at **16**-bit is deliberately absent at every rate: it spends ~1.41.5 Mbps to sound
* like the transparent 256 kbps Opus it replaces, and it is the one lossless request whose wire
* parameters are indistinguishable from a legacy one. 24-bit is where the plane earns its
* bandwidth.
*/
val AUDIO_FORMAT_OPTIONS = listOf(
AUDIO_FORMAT_OPUS to "Standard (Opus)",
AUDIO_FORMAT_LOSSLESS_441 to "Lossless 44.1 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_48 to "Lossless 48 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_882 to "Lossless 88.2 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_96 to "Lossless 96 kHz / 24-bit",
AUDIO_FORMAT_LOSSLESS_1764 to "Lossless 176.4 kHz / 24-bit",
)
/**
* The `(rateHz, bits)` pair [audioFormat] asks the host for, in `nativeConnect`'s terms.
*
* **Opus is `0`/`0`, the "did not ask" sentinel NOT `48000`/`16`.** Core sets
* `CLIENT_CAP_AUDIO_HIRES` when either field is non-zero, because it keys on *a format was
* specified* rather than *the format differs from the default*: 48 kHz/16-bit is the cheapest
* lossless rung as well as the legacy pair, so the other rule would make it the one rung nobody
* could ask for. Sending `48000`/`16` for a user who chose Standard therefore advertises the
* capability, and the host then hands that user 1.5 Mbps of lossless PCM instead of 256 kbps of
* Opus. This returned that pair until all four clients were compared.
*
* **That bug got worse on 2026-08-17, when the host's `PUNKTFUNK_AUDIO_HIRES` gate went
* default-ON.** It used to need a host whose operator had opted in rare, so a slip here would
* have been survivable and probably unnoticed. The blast radius is now every host that has not
* deliberately opted out, i.e. all of them. The zeroes below are load-bearing.
*
* The zeroes are also what keeps a default `Hello` byte-identical to a pre-lossless one the wire
* encodes an explicit 48 000/16 the same as absent, and the whole difference is the capability bit.
*
* Deriving the pair FROM the stored format is what stops the two ever disagreeing. An unrecognized
* stored value a newer build's, or a corrupted pref resolves to Opus rather than blocking the
* connect.
*
* The rate this returns is only the REQUEST. The native side runs it down a fallback ladder first
* (`session::connect::rate_fallback_ladder`), because AAudio grants an explicitly-asked rate or
* fails the open and never substitutes so a rate this device cannot play must never reach the
* wire.
*/
fun Settings.audioFormatWire(): Pair<Int, Int> = when (audioFormat) {
AUDIO_FORMAT_LOSSLESS_441 -> 44_100 to 24
AUDIO_FORMAT_LOSSLESS_48 -> 48_000 to 24
AUDIO_FORMAT_LOSSLESS_882 -> 88_200 to 24
AUDIO_FORMAT_LOSSLESS_96 -> 96_000 to 24
AUDIO_FORMAT_LOSSLESS_1764 -> 176_400 to 24
else -> AUDIO_FORMAT_WIRE_UNSPECIFIED
}
/**
* The `(rateHz, bits)` that mean "this session is not asking for the lossless plane" see
* [audioFormatWire] for why it is a pair of zeroes rather than the legacy 48 000/16.
*/
val AUDIO_FORMAT_WIRE_UNSPECIFIED = 0 to 0
/**
* (stored value, label) for the preferred video codec the cross-client table (the Rust
* `CODECS`), so a value another client or a profile stored is always representable here.
@@ -824,6 +824,23 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
field = "audio_channels",
caption = "Requested from the host; it downmixes if it has fewer.",
) { ch -> update(s.copy(audioChannels = ch)) }
// Offered at every channel count. It used to be hidden on 5.1/7.1, because a lossless
// surround frame did not fit one QUIC datagram at the default MTU — but the frame ladder is
// channel-aware, so a surround session negotiates a shorter frame instead of being refused,
// and only the top of this list genuinely fits nothing. Which rows a given session can
// actually have depends on the host, this device's output and the path MTU, none of which
// this screen knows; the HUD's `audio lossless …` line is what reports the answer.
SettingDropdown(
label = "Audio format",
options = AUDIO_FORMAT_OPTIONS,
selected = s.audioFormat,
field = "audio_format",
caption = "Lossless sends uncompressed audio on top of the video — 2.3 Mbps at " +
"48 kHz, 4.6 at 96, 8.5 at 176.4 — and the top rates are often declined, " +
"surround especially. The host has its own switch and both must be on; " +
"otherwise the session stays on Opus, which is already effectively " +
"transparent. The overlay shows what a session actually got.",
) { f -> update(s.copy(audioFormat = f)) }
ToggleRow(
title = "Microphone",
subtitle = "Feeds this device's microphone to the host",
@@ -945,9 +962,14 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padHaptics = on)) },
)
// The one row here that is OFF by default (see Settings.padSpeaker for why), which
// makes a silent pad speaker look exactly like broken hardware — the failure this
// subtitle exists to pre-empt, after it cost a full evening of host-side measuring.
// Say the default out loud rather than describing only what "on" does.
ToggleRow(
title = "Controller speaker",
subtitle = "Play audio the game sends to the controller's own speaker",
subtitle = "Play audio the game sends to the controller's own speaker" +
"off by default, so the pad's speaker stays silent until you turn this on",
checked = s.padSpeaker,
enabled = s.gamepadForwarding && s.dsCapture,
onCheckedChange = { on -> update(s.copy(padSpeaker = on)) },
@@ -18,13 +18,13 @@ import kotlin.math.roundToInt
* The live stats overlay the unified HUD (`design/stats-unification.md`): headline is
* `capturedisplayed` tiled by `host+network` + `decode` + `display` when the platform delivered
* OnFrameRendered render callbacks this window (`dispValid`), falling back to the v1
* `capturedecoded` headline without the `display` term when it didn't. Reads the 35-double
* `capturedecoded` headline without the `display` term when it didn't. Reads the 38-double
* layout from [NativeBridge.nativeVideoStats] (that KDoc is the authoritative index list):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skew, w, h, hz, lostTotal, bitDepth, colorPrimaries,
* colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms, netP50Ms, lost, skipped,
* fec, frames, dispValid, displayP50Ms, e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms,
* presentsWindow, presenterActive, feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs,
* audioAvOffsetMs]`. Every read
* audioAvOffsetMs, audioCodec, audioRateHz, audioBits]`. Every read
* is length-guarded, so an older native lib simply omits the lines it can't feed.
*
* The shown `display` and `end-to-end` numbers EXCLUDE the OS present floor (see [osFloorMs]) at
@@ -46,6 +46,10 @@ import kotlin.math.roundToInt
* - [StatsVerbosity.DETAILED] also the decoder label, the video-feed descriptor (1013), the
* stage equation (14/15, split into `host + network` when the Phase-2 terms at 16/17 are nonzero),
* the excluded-floor line when one was measured, and the audio plane's own latency (33/34).
*
* The RESOLVED audio format (3537) is the one figure that is not reserved for
* [StatsVerbosity.DETAILED] it renders from [StatsVerbosity.NORMAL] up, and only on a lossless
* session. See [audioFormatLine]. (Not on COMPACT, which is one line by definition.)
* [StatsVerbosity.OFF] renders nothing. Older native layouts simply omit the lines they lack (the
* counter line falls back to the cumulative `lostTotal` at index 9 on a pre-window lib).
*/
@@ -182,6 +186,15 @@ internal fun StatsOverlay(
if (detailed) {
audioLine(s)?.let { statLine(it, Color.White) }
}
// NOT gated to the detailed tier, unlike the audio latency above it, and deliberately: it
// is the one thing a user who turned lossless on needs to see. The failure it guards
// against (design/hi-res-audio.md §4.3, §10) is a session that costs 2.18.5 Mbps and
// delivers ordinary Opus, which is indistinguishable from success without a surface naming
// what the HOST resolved. `null` on the Opus plane every ordinary session runs, so the
// common case gains no line at all — and the top of the format menu is declined often
// enough (176.4 kHz fits only the ladder's shortest 1 ms rung, and hi-res surround fits no
// rung at all) that "the setting says one thing" is not evidence of anything.
audioFormatLine(s)?.let { statLine(it, Color(0xFFB0FFD0)) }
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
}
}
@@ -215,6 +228,50 @@ private fun audioLine(s: DoubleArray): String? {
return "audio buffer $bufferMs ms$avTerm"
}
/**
* The RESOLVED audio format from 3537 `audio lossless 96 kHz / 24-bit` or `null` on the Opus
* plane and on an older native layout.
*
* Deliberately silent for Opus rather than printing `audio opus 48 kHz`: that is what every
* session has always been, so a line stating it would be noise on the HUD of every user who never
* touched the setting. The line exists for the opposite case, and it is the only surface that can
* answer it: the format the SETTINGS screen shows is what this device REQUESTED, and the host's
* gate can decline every one of them (its own switch is off by default) leaving a session that
* looks, sounds and measures exactly like a granted one. The native side can also have downgraded
* the request before the handshake, if this device's output would not open the rate. Both land
* here as the truth.
*
* `codec` is the wire byte: 0 = Opus on `0xC9`, 2 = lossless PCM on `0xD3` (1 is reserved for a
* FLAC that was measured and not taken).
*
* The rate is rendered in kHz to one decimal when it needs one, because half the ladder does: the
* 44.1 kHz family (44 100 / 88 200 / 176 400) does not divide by a thousand, and printing raw Hz
* for it as this did while the ladder was 48/96 only put the settings menu's "44.1 kHz" next
* to a HUD saying "44100 Hz" and left the reader to decide whether those were the same session.
* The whole point of this line is that it is comparable at a glance with what was asked for.
*
* Built by integer division rather than `"%.1f".format()` deliberately: that formatter renders
* through the default locale and would say "44,1 kHz" on a device set to most of Europe a
* decimal comma where the settings row it is meant to be compared against has a point. Every rate
* this plane carries is a whole number of hundreds of Hz, so the tenths digit is exact.
*/
private fun audioFormatLine(s: DoubleArray): String? {
if (s.size < 38) return null
if (s[35].roundToInt() != AUDIO_CODEC_PCM_WIRE) return null
val rateHz = s[36].roundToInt()
val bits = s[37].roundToInt()
if (rateHz <= 0 || bits <= 0) return null
val khz = if (rateHz % 1000 == 0) {
"${rateHz / 1000} kHz"
} else {
"${rateHz / 1000}.${rateHz % 1000 / 100} kHz"
}
return "audio lossless $khz / $bits-bit"
}
/** `quic::AUDIO_CODEC_PCM` — the `0xD3` lossless plane's wire byte. */
private const val AUDIO_CODEC_PCM_WIRE = 2
/** One monospace HUD line — the shared type ramp so every tier's rows line up. */
@Composable
private fun statLine(text: String, color: Color) {
@@ -58,7 +58,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
@@ -420,10 +419,8 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(true)
}
controller?.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars())
}
// System bars: NOT hidden here — App.kt owns hide/show (one owner; the AnimatedContent
// handoff broke per-screen ownership, see the `immersive` effect there).
// The soft keyboard (three-finger swipe up → KeyCaptureView below) must OVERLAY the
// stream, never pan/resize it — the video is a fixed-mode surface, not a document.
// Scoped to the stream; the app's other screens keep the default for their text fields.
@@ -817,7 +814,6 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
}
}
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(false)
@@ -916,6 +912,12 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
// The SurfaceView's on-screen pixel size — the coordinate space the
// ASurfaceControl layer composites in (the aspect-fitted video rect,
// not the window's rotated buffer geometry). 0 if not laid out yet;
// native falls back to the window buffer size.
this@apply.width,
this@apply.height,
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode, isTv)
// The MIC grant is read live (a surface recreate re-runs this, and
@@ -938,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
// The view's CURRENT pixel size, for the ASurfaceControl layer's
// destination rect. It is reported here and not only at
// surfaceCreated because the view grows a frame or two after the
// stream screen appears — hiding the system bars and switching on
// cutout drawing both resize it, and neither recreates the surface.
// A layer left on the start-up rect paints the picture small, in the
// top-left corner. The view's own size, not the buffer geometry in
// `width`/`height`: the layer composites in the view's space.
NativeBridge.nativeVideoSurfaceSize(
handle, this@apply.width, this@apply.height,
)
// Re-assert the frame-rate vote: a buffer-geometry change can reset
// the surface's frame-rate setting on some OEM builds, silently
// dropping the 120 Hz pin mid-stream. Mirrors the native hint's
@@ -0,0 +1,391 @@
package io.unom.punktfunk.console
import android.view.InputDevice
import io.unom.punktfunk.MouseMode
import io.unom.punktfunk.Settings
import io.unom.punktfunk.StatsVerbosity
import io.unom.punktfunk.StreamProfile
import io.unom.punktfunk.TouchMode
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.padInfoOf
import org.json.JSONArray
import org.json.JSONObject
/**
* The JSON that crosses into the Skia console written in the console's OWN model shapes
* (`crates/pf-console-ui/src/model.rs` `HostRow`/`WakeStatus`/`PairPhase`, `library.rs`
* `LibraryGame`/`LibraryPhase`, `pf-client-core/src/trust.rs` `Settings`/`KnownHosts`), so there
* is no Android-side mirror type to drift; the Rust structs deserialize these directly.
*/
internal object ConsoleJson {
// ---- host rows (`HostRow`) ------------------------------------------------------------
/** `HostRow.key` — the pinned fingerprint when there is one, else `addr:port` (Rust parity). */
fun rowKey(fpHex: String, address: String, port: Int): String =
if (fpHex.isEmpty()) "$address:$port" else fpHex
private fun profileChip(p: StreamProfile): JSONObject = JSONObject()
.put("id", p.id)
.put("name", p.name)
.put("accent", p.accent ?: JSONObject.NULL)
/**
* The home carousel: saved hosts (name order Android records carry no last-used time),
* each followed by its pinned profile cards, then discovered-but-unsaved hosts. Mirrors
* `clients/session/src/console.rs::rows()` the desktop service's ordering so a
* player who moves between a Deck and a phone finds the same carousel.
*/
fun hostRows(
saved: List<KnownHost>,
discovered: List<DiscoveredHost>,
reachable: Set<String>,
profiles: List<StreamProfile>,
): String {
val out = JSONArray()
fun advertFor(h: KnownHost): DiscoveredHost? = discovered.firstOrNull { d ->
(h.fpHex.isNotEmpty() && d.fingerprint.equals(h.fpHex, ignoreCase = true)) ||
(d.host == h.address && d.port == h.port)
}
for (h in saved.sortedBy { it.name.lowercase() }) {
val key = rowKey(h.fpHex, h.address, h.port)
val advert = advertFor(h)
val online = advert != null || "${h.address}:${h.port}" in reachable
val base = JSONObject()
.put("key", key)
.put("name", h.name.ifBlank { h.address })
.put("addr", h.address)
.put("port", h.port)
.put("fp_hex", h.fpHex)
.put("paired", h.paired)
.put("saved", true)
.put("online", online)
.put("mgmt_port", advert?.mgmtPort ?: h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", !online && h.mac.isNotEmpty())
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", advert?.os?.takeIf { it.isNotEmpty() } ?: h.os)
.put("pin", JSONObject.NULL)
.put(
"bound_profile",
h.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
?.let(::profileChip) ?: JSONObject.NULL,
)
out.put(base)
// A pinned card shares the primary tile's live state; its key rides the profile id
// behind a NUL (impossible in a fingerprint or `addr:port`) — Rust parity.
for (pid in h.pinnedProfileIds) {
val p = profiles.firstOrNull { it.id == pid } ?: continue
out.put(
JSONObject(base.toString())
.put("key", "$key\u0000${p.id}")
.put("pin", profileChip(p))
.put("bound_profile", JSONObject.NULL),
)
}
}
val extra = discovered.filter { d ->
saved.none { h ->
(h.fpHex.isNotEmpty() && h.fpHex.equals(d.fingerprint, ignoreCase = true)) ||
(h.address == d.host && h.port == d.port)
}
}.sortedBy { it.name.lowercase() }
for (d in extra) {
val fp = d.fingerprint.orEmpty()
out.put(
JSONObject()
.put("key", rowKey(fp, d.host, d.port))
.put("name", d.name.ifBlank { d.host })
.put("addr", d.host)
.put("port", d.port)
.put("fp_hex", fp)
.put("paired", false)
.put("saved", false)
.put("online", true)
.put("mgmt_port", d.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", false)
.put("last_used", JSONObject.NULL)
.put("os", d.os)
.put("pin", JSONObject.NULL)
.put("bound_profile", JSONObject.NULL),
)
}
return out.toString()
}
/** One `HostRow` for a console entry (`{"library": <HostRow>}`) — the shelf to open. */
fun hostRow(h: KnownHost, pin: StreamProfile?, profiles: List<StreamProfile>): JSONObject {
val key = rowKey(h.fpHex, h.address, h.port)
return JSONObject()
.put("key", if (pin == null) key else "$key\u0000${pin.id}")
.put("name", h.name.ifBlank { h.address })
.put("addr", h.address)
.put("port", h.port)
.put("fp_hex", h.fpHex)
.put("paired", h.paired)
.put("saved", true)
.put("online", true)
.put("mgmt_port", h.mgmtPort ?: DEFAULT_MGMT_PORT)
.put("can_wake", false)
.put("clipboard_sync", h.clipboardSync)
.put("last_used", JSONObject.NULL)
.put("os", h.os)
.put("pin", pin?.let(::profileChip) ?: JSONObject.NULL)
.put(
"bound_profile",
if (pin != null) JSONObject.NULL
else h.profileId?.let { id -> profiles.firstOrNull { it.id == id } }
?.let(::profileChip) ?: JSONObject.NULL,
)
}
/** `KnownHosts` (Rust) — only what the console needs to build a link: id, address, fp. */
fun knownHosts(saved: List<KnownHost>): String {
val hosts = JSONArray()
for (h in saved) {
hosts.put(
JSONObject()
.put("name", h.name)
.put("addr", h.address)
.put("port", h.port)
.put("fp_hex", h.fpHex)
.put("paired", h.paired)
.put("id", h.id)
.put("mac", JSONArray(h.mac))
.put("os", h.os)
.put("mgmt_port", h.mgmtPort ?: JSONObject.NULL)
.put("profile_id", h.profileId ?: JSONObject.NULL)
.put("pinned_profiles", JSONArray(h.pinnedProfileIds)),
)
}
return JSONObject().put("hosts", hosts).toString()
}
fun profiles(profiles: List<StreamProfile>): String {
val out = JSONArray()
for (p in profiles) out.put(JSONArray().put(p.id).put(p.name))
return out.toString()
}
// ---- wake / pair ------------------------------------------------------------------------
fun wakeStatus(
key: String,
name: String,
seconds: Int,
timedOut: Boolean,
online: Boolean,
thenConnect: Boolean,
): String = JSONObject()
.put("key", key)
.put("name", name)
.put("seconds", seconds)
.put("timed_out", timedOut)
.put("online", online)
.put("then_connect", thenConnect)
.toString()
fun pairIdle(): String = "\"Idle\""
fun pairBusy(): String = "\"Busy\""
fun pairFailed(msg: String): String = JSONObject().put("Failed", msg).toString()
fun pairPaired(key: String): String =
JSONObject().put("Paired", JSONObject().put("key", key)).toString()
// ---- library ------------------------------------------------------------------------------
/** `[LibraryGame]` from the Kotlin catalog — the desktop service's `to_model` mapping. */
fun libraryGames(games: List<GameEntry>): String {
val out = JSONArray()
for (g in games) {
out.put(
JSONObject()
.put("id", g.id)
.put("title", g.title)
.put("store", g.store)
.put("launcher", g.isLauncher)
.put("icon", g.icon?.takeIf(::validIconToken) ?: "")
.put("platform", g.platform ?: JSONObject.NULL)
.put("running", false),
)
}
return out.toString()
}
/** `GameEntry::icon_token`'s re-validation: lowercase-first, ≤ 32 chars of [a-z0-9-]. */
private fun validIconToken(t: String): Boolean =
t.isNotEmpty() && t.length <= 32 && t[0] in 'a'..'z' &&
t.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
fun libraryError(title: String, body: String, canRetry: Boolean): String = JSONObject()
.put(
"Error",
JSONObject().put("title", title).put("body", body).put("can_retry", canRetry),
)
.toString()
fun stringArray(items: Collection<String>): String = JSONArray(items).toString()
// ---- pads -------------------------------------------------------------------------------
/**
* `{"label", "pref", "pads": [...]}` the controller chip's text (the driving pad's name),
* the glyph style's pref byte, and one entry per connected pad for the settings rows and the
* console's Connected-controllers screen.
*
* `detail`/`forwarded`/`rumble` come straight from [padInfoOf], the same reader the touch
* Controllers screen renders from: the support answer a user gets must not depend on which
* interface asked, and two readers of `InputDevice` would be two answers waiting to drift.
*/
fun pads(pads: List<InputDevice>, driving: InputDevice?): String {
val arr = JSONArray()
for (d in pads) {
val info = padInfoOf(d)
val entry = JSONObject()
.put("name", d.name)
.put("key", "${d.vendorId}:${d.productId}:${d.name}")
.put("pref", Gamepad.prefFor(d))
.put("steam_virtual", false)
.put("detail", info.detail)
.put("forwarded", info.forwarded)
.put("rumble", info.canRumble)
val battery = if (android.os.Build.VERSION.SDK_INT >= 31) {
val b = d.batteryState
if (b.isPresent && b.capacity >= 0f) {
JSONObject()
.put("percent", (b.capacity * 100f).toInt().coerceIn(0, 100))
.put(
"charging",
b.status == android.os.BatteryManager.BATTERY_STATUS_CHARGING ||
b.status == android.os.BatteryManager.BATTERY_STATUS_FULL,
)
} else null
} else null
entry.put("battery", battery ?: JSONObject.NULL)
arr.put(entry)
}
return JSONObject()
.put("label", driving?.name ?: JSONObject.NULL)
.put("pref", driving?.let { Gamepad.prefFor(it) } ?: JSONObject.NULL)
.put("pads", arr)
.toString()
}
// ---- settings (`trust::Settings`) -------------------------------------------------------
private val GAMEPAD_NAMES = listOf(
"auto", "xbox360", "dualsense", "xboxone", "dualshock4", "steamcontroller", "steamdeck",
"dualsenseedge", "switchpro", "steamcontroller2", "steamcontroller2puck", "xboxelite",
)
private val COMPOSITOR_NAMES = listOf("auto", "kwin", "wlroots", "mutter", "gamescope")
/**
* The console's settings document: [base] is the last snapshot the console saved (it owns
* keys Android has no field for `library_sort`, `library_view`, `reduce_motion`, ), and
* every field Android DOES own is written over it from [s], so the touch UI's edits win.
* `trust::Settings` is `#[serde(default)]`, so a partial document is fine.
*/
fun settings(s: Settings, base: JSONObject?): JSONObject {
val j = base?.let { JSONObject(it.toString()) } ?: JSONObject()
j.put("width", s.width)
j.put("height", s.height)
j.put("refresh_hz", s.hz)
j.put("bitrate_kbps", s.bitrateKbps)
j.put("render_scale", s.renderScale)
j.put("gamepad", GAMEPAD_NAMES.getOrElse(s.gamepad) { "auto" })
j.put("gamepad_forwarding", s.gamepadForwarding)
j.put("system_buttons", s.systemButtons)
j.put("guide_gesture", s.guideGesture)
j.put("compositor", COMPOSITOR_NAMES.getOrElse(s.compositor) { "auto" })
j.put("touch_mode", s.touchMode.name.lowercase())
j.put("mouse_mode", s.mouseMode.storedName)
j.put("mic_enabled", s.micEnabled)
j.put("echo_cancel", s.echoCancel)
j.put("audio_channels", s.audioChannels)
j.put("audio_format", s.audioFormat)
j.put("codec", s.codec)
j.put("hdr_enabled", s.hdrEnabled)
j.put("present_priority", s.presentPriority)
j.put("smooth_buffer", s.smoothBuffer)
j.put("show_stats", s.statsVerbosity != StatsVerbosity.OFF)
j.put("stats_verbosity", s.statsVerbosity.name.lowercase())
j.put("ui_palette", s.uiPalette)
j.put("auto_wake", s.autoWakeEnabled)
j.put("invert_scroll", s.invertScroll)
j.put("pad_haptics", s.padHaptics)
j.put("pad_speaker", if (s.padSpeaker) "pad" else "off")
// Android-only rows ride `Settings::extra`, which is `#[serde(flatten)]` — so they are
// TOP-LEVEL keys of this document, not a nested `extra` object. Nesting them put the
// whole object into the map under the literal key "extra", where no console row could
// read it and every value the console wrote came straight back as the one we had sent.
j.put("android.low_latency", s.lowLatencyMode)
j.put("android.rumble_on_phone", s.rumbleOnPhone)
j.put("android.gyro_on_phone", s.gyroOnPhone)
j.put("android.sc2_capture", s.sc2Capture)
j.put("android.ds_capture", s.dsCapture)
j.put("android.gamepad_ui_mode", s.gamepadUiMode)
j.put("android.gamepad_ui_enabled", s.gamepadUiEnabled)
// A store written by the nesting build carries the stale wrapper; drop it rather than
// round-trip a copy of these keys that nothing reads for the life of the install.
j.remove("extra")
return j
}
/**
* The console saved [j]: fold every key Android owns back into [s]. Unknown values snap to
* the field's current value a newer console's spelling must never corrupt the store.
*/
fun applySettings(s: Settings, j: JSONObject): Settings {
fun str(k: String, cur: String) = j.optString(k, cur).ifEmpty { cur }
// The `android.*` keys are TOP-LEVEL here, not nested: `Settings::extra` is
// `#[serde(flatten)]`, so the console writes them beside `width` and `codec`.
return s.copy(
width = j.optInt("width", s.width),
height = j.optInt("height", s.height),
hz = j.optInt("refresh_hz", s.hz),
bitrateKbps = j.optInt("bitrate_kbps", s.bitrateKbps),
renderScale = j.optDouble("render_scale", s.renderScale),
gamepad = GAMEPAD_NAMES.indexOf(str("gamepad", "")).takeIf { it >= 0 } ?: s.gamepad,
gamepadForwarding = j.optBoolean("gamepad_forwarding", s.gamepadForwarding),
systemButtons = str("system_buttons", s.systemButtons),
guideGesture = str("guide_gesture", s.guideGesture),
compositor = COMPOSITOR_NAMES.indexOf(str("compositor", "")).takeIf { it >= 0 }
?: s.compositor,
touchMode = TouchMode.entries.firstOrNull { it.name.lowercase() == j.optString("touch_mode") }
?: s.touchMode,
mouseMode = MouseMode.entries.firstOrNull { it.storedName == j.optString("mouse_mode") }
?: s.mouseMode,
micEnabled = j.optBoolean("mic_enabled", s.micEnabled),
echoCancel = j.optBoolean("echo_cancel", s.echoCancel),
audioChannels = j.optInt("audio_channels", s.audioChannels),
audioFormat = str("audio_format", s.audioFormat),
codec = str("codec", s.codec),
hdrEnabled = j.optBoolean("hdr_enabled", s.hdrEnabled),
presentPriority = str("present_priority", s.presentPriority),
smoothBuffer = j.optInt("smooth_buffer", s.smoothBuffer),
statsVerbosity = StatsVerbosity.entries
.firstOrNull { it.name.lowercase() == j.optString("stats_verbosity") }
?: s.statsVerbosity,
uiPalette = str("ui_palette", s.uiPalette),
autoWakeEnabled = j.optBoolean("auto_wake", s.autoWakeEnabled),
invertScroll = j.optBoolean("invert_scroll", s.invertScroll),
padHaptics = j.optBoolean("pad_haptics", s.padHaptics),
padSpeaker = when (j.optString("pad_speaker", "")) {
"pad", "mix" -> true
"off" -> false
else -> s.padSpeaker
},
lowLatencyMode = j.optBoolean("android.low_latency", s.lowLatencyMode),
rumbleOnPhone = j.optBoolean("android.rumble_on_phone", s.rumbleOnPhone),
gyroOnPhone = j.optBoolean("android.gyro_on_phone", s.gyroOnPhone),
sc2Capture = j.optBoolean("android.sc2_capture", s.sc2Capture),
dsCapture = j.optBoolean("android.ds_capture", s.dsCapture),
gamepadUiMode = j.optString("android.gamepad_ui_mode", s.gamepadUiMode)
.ifEmpty { s.gamepadUiMode },
gamepadUiEnabled = j.optBoolean("android.gamepad_ui_enabled", s.gamepadUiEnabled),
)
}
}
@@ -0,0 +1,832 @@
package io.unom.punktfunk.console
import android.app.ActivityManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.unom.punktfunk.CONNECT_TIMEOUT_MS
import io.unom.punktfunk.ConnectErrors
import io.unom.punktfunk.ProfileStore
import io.unom.punktfunk.Settings
import io.unom.punktfunk.SettingsStore
import io.unom.punktfunk.StreamProfile
import io.unom.punktfunk.connectToHost
import io.unom.punktfunk.deviceName
import io.unom.punktfunk.effectiveFor
import io.unom.punktfunk.matches
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.discovery.HostDiscovery
import io.unom.punktfunk.kit.library.LibraryCache
import io.unom.punktfunk.kit.library.LibraryClient
import io.unom.punktfunk.kit.library.LibraryResult
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.kit.security.obtainIdentity
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* The Skia console (`crates/pf-console-ui`, drawn by native over EGL design
* `android-skia-console-port.md`) as this app holds it: ONE instance for the process, created
* lazily and never torn down while the app lives, so the console's screen stack survives a trip
* through the stream exactly as the desktop's does (the shelf is where you left it when the game
* exits). [SkiaConsoleShell] attaches a surface, the pad probes and the overlays to it while the
* console is on screen; between, it idles parked.
*
* This object is the SERVICE side of the console's model (`ConsoleShared` / `LibraryShared` /
* `ConsoleBus`): it feeds host rows from the trust store + discovery + the reachability probe,
* runs the library fetch/cache/art pipeline, pairing, wake-and-wait, and the settings round-trip,
* and turns the console's own asks (`OverlayAction`) into a connect, a clipboard write, or a
* task-to-back. Everything blocking runs on its own executor; every native call is cheap.
*/
object SkiaConsole {
private const val TAG = "pf.console"
/**
* On-glass triage switch: `adb shell setprop debug.punktfunk.console_backend none` makes the
* app behave as if the native console host were absent (the touch UI fronts everything, a
* controller drives it through Compose focus). Anything else = the console.
*/
private const val BACKEND_PROP = "debug.punktfunk.console_backend"
/** Where the console-owned settings keys (`library_view`, `reduce_motion`, …) persist. */
private const val PREFS = "punktfunk_console_settings"
private var handle = 0L
/**
* False once the console has proven it cannot draw the native create failed, or the render
* thread died (a GL context that never came up, or one Android reclaimed and that would not
* come back). Compose observes it: `App` folds it into the gamepad-UI gate, so the answer to a
* dead console is the touch UI not the gray, never-painted `SurfaceView` the shell would
* otherwise sit on for the rest of the process.
*/
var healthy by mutableStateOf(true)
private set
private var appContext: Context? = null
private val main = Handler(Looper.getMainLooper())
private val ioPool = Executors.newCachedThreadPool { r -> Thread(r, "pf-console-io").apply { isDaemon = true } }
private val artPool = Executors.newFixedThreadPool(3) { r -> Thread(r, "pf-console-art").apply { isDaemon = true } }
private val artHttp by lazy { OkHttpClient() }
private var eventThread: Thread? = null
private val running = AtomicBoolean(false)
// Services.
private lateinit var knownHostStore: KnownHostStore
private lateinit var profileStore: ProfileStore
private lateinit var settingsStore: SettingsStore
private var identity: ClientIdentity? = null
private var discovery: HostDiscovery? = null
private var discovered: List<DiscoveredHost> = emptyList()
private var reachable: Set<String> = emptySet()
private var settings: Settings = Settings()
// What the composable hands us while it is on screen.
private var onConnected: ((ActiveSession) -> Unit)? = null
private var onSettingsChange: ((Settings) -> Unit)? = null
private var onQuit: (() -> Unit)? = null
private var onPlatformScreen: ((String) -> Unit)? = null
private var onPadAction: ((String, String) -> Unit)? = null
private var onPulse: ((String) -> Unit)? = null
/** The connect in flight, if any — cancelable through `OverlayAction::CancelConnect`. */
private class Dial(val cancelled: AtomicBoolean = AtomicBoolean(false))
private var dial: Dial? = null
/** The wake-and-wait loop in flight, if any. */
private var wakeGen = AtomicLong(0)
/** The library fetch in flight (its generation; a newer one supersedes it). */
private val fetchGen = AtomicLong(0)
// ---- availability -------------------------------------------------------------------
/**
* Whether the console can front the gamepad UI on this device: the native host must be in
* this build (every shipping ABI today see `nativeConsoleAvailable`) and the triage sysprop
* must not say `none`.
*/
fun wanted(): Boolean {
val available = runCatching { NativeBridge.nativeConsoleAvailable() }.getOrDefault(false)
if (!available) return false
return backendProp() != "none"
}
private fun backendProp(): String = runCatching {
val cls = Class.forName("android.os.SystemProperties")
cls.getMethod("get", String::class.java, String::class.java)
.invoke(null, BACKEND_PROP, "") as String
}.getOrDefault("").trim().lowercase()
// ---- lifecycle -----------------------------------------------------------------------
/**
* Build the console if it does not exist yet. Idempotent; call from the main thread. Returns
* the native handle (`0` = the console could not be built; the caller keeps the Compose
* console).
*/
fun ensure(context: Context, initial: Settings): Long {
if (handle != 0L) return handle
val app = context.applicationContext
appContext = app
knownHostStore = KnownHostStore(app)
profileStore = ProfileStore(app)
settingsStore = SettingsStore(app)
settings = initial
val prefs = app.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val base = prefs.getString("json", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
val profiles = profileStore.all()
val opts = JSONObject()
.put("device_name", deviceName(app))
.put("gpu_cache_bytes", gpuCacheBytes(app))
// The touch shell exists as a fallback on phones/tablets but not on a TV —
// gates the console's own "Controller-optimized UI" off switch.
.put("fallback_ui", !io.unom.punktfunk.isTvDevice(app))
.put("settings", ConsoleJson.settings(initial, base))
.put("profiles", JSONArray(ConsoleJson.profiles(profiles)))
.put("known_hosts", JSONObject(ConsoleJson.knownHosts(knownHostStore.all())))
.put("entry", JSONObject())
handle = runCatching { NativeBridge.nativeConsoleCreate(opts.toString()) }.getOrDefault(0L)
if (handle == 0L) {
Log.e(TAG, "console: native create failed")
healthy = false // see [healthy] — the touch UI fronts everything from here
return 0L
}
Log.i(TAG, "console: created (gpu cache ${gpuCacheBytes(app) shr 20} MB)")
startEventThread()
startServices(app)
return handle
}
/**
* Skia's resource budget: a quarter of the desktop's 160 MB on a 2 GB box, the desktop
* figure above (design D11 a 160 MB texture cache is how a TV box gets its process killed).
*/
private fun gpuCacheBytes(context: Context): Int {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
val classMb = am?.memoryClass ?: 128
return if (classMb >= 256) 160 shl 20 else 64 shl 20
}
private fun startServices(app: Context) {
ioPool.execute {
identity = runCatching { obtainIdentity(IdentityStore(app)) }
.onFailure { Log.w(TAG, "identity unavailable: ${it.message}") }
.getOrNull()
}
val d = HostDiscovery(app)
d.onChange = { list ->
discovered = list
// Learn wake MACs / mgmt ports from live adverts, as the desktop service does.
ioPool.execute {
var changed = false
for (dh in list) {
val kh = knownHostStore.all().firstOrNull { it.matches(dh) } ?: continue
if (dh.mac.isNotEmpty() && dh.mac.toSet() != kh.mac.toSet()) {
knownHostStore.learnMac(kh.address, kh.port, dh.mac); changed = true
}
dh.mgmtPort?.let { if (it != kh.mgmtPort) { knownHostStore.learnMgmtPort(kh.address, kh.port, it); changed = true } }
if (dh.os.isNotEmpty() && dh.os != kh.os) { knownHostStore.learnOs(kh.address, kh.port, dh.os); changed = true }
}
main.post { pushHosts(); if (changed) pushKnownHosts() }
}
pushHosts()
}
discovery = d
d.start()
// The reachability sweep: saved hosts not on mDNS, every ~12 s (the desktop's cadence).
main.post(object : Runnable {
override fun run() {
if (handle == 0L) return
// Only while the console is ON SCREEN (attached): parked behind the touch UI
// or a stream there is nobody to show the presence pips to — and mid-stream
// the radio belongs to the session, which is exactly why discovery stops for
// it. The timer keeps ticking so probes resume within a cadence of re-attach.
if (onConnected == null) {
main.postDelayed(this, 12_000)
return
}
val targets = knownHostStore.all().filter { kh -> discovered.none { kh.matches(it) } }
ioPool.execute {
val up = targets.filter { NativeBridge.nativeProbe(it.address, it.port, 3_000) }
.map { "${it.address}:${it.port}" }.toSet()
main.post { if (up != reachable) { reachable = up; pushHosts() } }
}
main.postDelayed(this, 12_000)
}
})
// Commands from the console, drained on a short cadence.
main.post(object : Runnable {
override fun run() {
if (handle == 0L) return
drainCommands()
main.postDelayed(this, 100)
}
})
pushHosts()
}
private fun startEventThread() {
running.set(true)
eventThread = Thread({
while (running.get() && handle != 0L) {
val json = runCatching { NativeBridge.nativeConsoleNextEvent(handle) }.getOrDefault("")
if (json.isEmpty()) continue
val ev = runCatching { JSONObject(json) }.getOrNull() ?: continue
main.post { onEvent(ev) }
}
}, "pf-console-events").apply { isDaemon = true; start() }
}
// ---- what the composable attaches ------------------------------------------------------
fun attach(
onConnected: (ActiveSession) -> Unit,
onSettingsChange: (Settings) -> Unit,
onQuit: () -> Unit,
onPlatformScreen: (String) -> Unit,
onPadAction: (String, String) -> Unit,
onPulse: (String) -> Unit,
) {
this.onConnected = onConnected
this.onSettingsChange = onSettingsChange
this.onQuit = onQuit
this.onPlatformScreen = onPlatformScreen
this.onPadAction = onPadAction
this.onPulse = onPulse
discovery?.restart()
// The touch UI may have paired/forgotten/edited hosts or profiles while we were away.
pushHosts()
pushKnownHosts()
if (handle != 0L) NativeBridge.nativeConsoleSetProfiles(handle, ConsoleJson.profiles(profileStore.all()))
}
fun detach() {
onConnected = null
onSettingsChange = null
onQuit = null
onPlatformScreen = null
onPadAction = null
onPulse = null
}
/** The touch UI (or a link) changed settings: the console reads the new snapshot next. */
fun settingsChanged(s: Settings) {
settings = s
if (handle == 0L) return
val prefs = appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val base = prefs?.getString("json", null)?.let { runCatching { JSONObject(it) }.getOrNull() }
NativeBridge.nativeConsoleSetSettings(handle, ConsoleJson.settings(s, base).toString())
}
/** The profile catalog changed (the touch settings edited it). */
fun profilesChanged() {
if (handle == 0L) return
NativeBridge.nativeConsoleSetProfiles(handle, ConsoleJson.profiles(profileStore.all()))
pushHosts()
}
/** The host store changed outside the console (touch UI pairing / forget). */
fun hostsChanged() {
if (handle == 0L) return
pushHosts()
pushKnownHosts()
}
/** A session the console started (or any session) has ended; [reason] = the abnormal one. */
fun sessionEnded(reason: String?) {
if (handle == 0L) return
NativeBridge.nativeConsoleSessionPhase(handle, 3, reason.orEmpty())
discovery?.restart()
}
/** Re-root the console on a host's shelf (a game launched from it just exited; a deep link). */
fun openLibrary(hostId: String, pinId: String?) {
if (handle == 0L) return
val kh = knownHostStore.byId(hostId) ?: return
val profiles = profileStore.all()
val pin = pinId?.let { id -> profiles.firstOrNull { it.id == id } }
val entry = JSONObject().put("library", ConsoleJson.hostRow(kh, pin, profiles))
NativeBridge.nativeConsoleNavigate(handle, entry.toString())
}
/**
* A `punktfunk://` link while the console is up. Known-and-pinned is the one-click contract
* (the same dial the console's own Launch takes); anything that would need a trust decision
* is a notice here a link may never establish trust, and the console's Pair screen is
* reached from the host's tile, not from a URL.
*/
fun handleDeepLink(url: String) {
if (handle == 0L) return
val parsed = io.unom.punktfunk.kit.link.DeepLinks.parse(url)
if (parsed is io.unom.punktfunk.kit.link.DeepLinkResult.Refused) {
if (parsed.error != io.unom.punktfunk.kit.link.LinkError.NOT_OUR_SCHEME) notice(parsed.message())
return
}
val link = (parsed as io.unom.punktfunk.kit.link.DeepLinkResult.Parsed).link
if (link.route != io.unom.punktfunk.kit.link.LinkRoute.CONNECT) {
notice("Punktfunk on Android can't do “${link.route.word}” links yet.")
return
}
val profileRef = link.profile
if (profileRef != null) {
val (_, resolution) = profileStore.resolve(profileRef)
if (resolution != io.unom.punktfunk.ProfileResolution.FOUND) {
notice("That link asks for a profile called “$profileRef”, which isn't on this device.")
return
}
}
when (val resolved = io.unom.punktfunk.kit.link.DeepLinks.resolveHost(link, knownHostStore.all())) {
is io.unom.punktfunk.kit.link.HostResolution.Known -> {
val kh = resolved.host
if (link.pinConflict(kh)) {
notice("That link's fingerprint doesn't match the one pinned for ${kh.name}.")
return
}
if (kh.fpHex.isEmpty() || !kh.paired) {
notice("Pair with ${kh.name} first — a link can't establish trust.")
return
}
launch(
JSONObject()
.put("addr", kh.address).put("port", kh.port).put("fp_hex", kh.fpHex)
.put("launch", link.launch ?: JSONObject.NULL)
.put("profile", profileRef?.let { profileStore.resolve(it).first?.id } ?: JSONObject.NULL)
.put("request_access", false),
)
}
is io.unom.punktfunk.kit.link.HostResolution.Unknown ->
notice("That link points at a host this device hasn't paired with.")
io.unom.punktfunk.kit.link.HostResolution.Ambiguous ->
notice("More than one saved host is called “${link.hostRef}”.")
io.unom.punktfunk.kit.link.HostResolution.Unresolvable ->
notice("That link points at a host this device doesn't know.")
}
}
/** The connected controllers, for the chip + settings rows. */
fun padsChanged(driving: InputDevice?) {
if (handle == 0L) return
NativeBridge.nativeConsoleSetPads(handle, ConsoleJson.pads(Gamepad.pads(), driving ?: Gamepad.firstPad()))
}
// ---- model pushers -----------------------------------------------------------------------
private fun pushHosts() {
if (handle == 0L) return
NativeBridge.nativeConsoleSetHosts(
handle,
ConsoleJson.hostRows(knownHostStore.all(), discovered, reachable, profileStore.all()),
)
}
private fun pushKnownHosts() {
if (handle == 0L) return
NativeBridge.nativeConsoleSetKnownHosts(handle, ConsoleJson.knownHosts(knownHostStore.all()))
}
internal fun notice(text: String) {
if (handle != 0L) NativeBridge.nativeConsoleNotice(handle, text)
}
// ---- events from the console ---------------------------------------------------------
private fun onEvent(ev: JSONObject) {
when {
ev.has("action") -> onAction(ev.get("action"))
ev.has("pulse") -> onPulse?.invoke(ev.optString("pulse"))
ev.has("editing") -> {} // the shell draws its own keyboard; nothing to raise here
ev.has("settings") -> onSettingsSaved(ev.getJSONObject("settings"))
ev.has("gles") -> Log.i(TAG, "console: GLES ${ev.optInt("gles")}")
ev.has("dead") -> {
Log.e(TAG, "console: render thread died: ${ev.optString("dead")}")
healthy = false // the touch UI takes over; only a process restart tries again
}
}
}
private fun onSettingsSaved(j: JSONObject) {
appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()
?.putString("json", j.toString())?.apply()
val next = ConsoleJson.applySettings(settings, j)
if (next != settings) {
settings = next
settingsStore.save(next)
onSettingsChange?.invoke(next)
}
}
private fun onAction(action: Any) {
when (action) {
is String -> when (action) {
"Quit" -> onQuit?.invoke()
"CancelConnect" -> {
dial?.cancelled?.set(true)
dial = null
discovery?.restart()
}
}
is JSONObject -> {
action.optJSONObject("Launch")?.let(::launch)
action.optString("CopyText").takeIf { action.has("CopyText") }?.let { text ->
val cm = appContext?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
cm?.setPrimaryClip(ClipData.newPlainText("punktfunk", text))
}
}
}
}
/**
* `OverlayAction::Launch` the console asked for a session. The trust decision was the
* console's (an unpaired host went to its Pair screen first), so this is the dial itself:
* pinned by the row's fingerprint, with the host's bound profile or the pinned card's
* one-off, and for the pair screen's "Request access" the long approval budget.
*/
private fun launch(a: JSONObject) {
val app = appContext ?: return
val addr = a.optString("addr")
val port = a.optInt("port")
val fp = a.optString("fp_hex")
val launchId = a.optString("launch").takeIf { a.has("launch") && !a.isNull("launch") && it.isNotEmpty() }
val profileId = a.optString("profile").takeIf { a.has("profile") && !a.isNull("profile") && it.isNotEmpty() }
val requestAccess = a.optBoolean("request_access", false)
val id = identity
if (id == null) {
NativeBridge.nativeConsoleSessionPhase(handle, 2, "Identity not ready yet — try again in a moment")
return
}
val kh = knownHostStore.get(addr, port)
val profile: StreamProfile? = profileStore.resolveFor(kh, profileId)
val effective = settings.effectiveFor(profile)
val d = Dial()
dial = d
NativeBridge.nativeConsoleSessionPhase(handle, 0, "")
discovery?.stop() // free the Wi-Fi radio before the stream session
ioPool.execute {
val timeout = if (requestAccess) REQUEST_ACCESS_TIMEOUT_MS else CONNECT_TIMEOUT_MS
val h = kotlinx.coroutines.runBlocking {
connectToHost(app, effective, id, addr, port, fp, launchId, timeout)
}
main.post {
if (d.cancelled.get()) {
if (h != 0L) ioPool.execute { NativeBridge.nativeClose(h) }
return@post
}
dial = null
if (h != 0L) {
var record = kh
// A request-access approval, or a first TOFU-less connect: save the host as
// PAIRED, pinning what it presented, so the next connect is silent.
if (record == null || (requestAccess && !record.paired)) {
val seen = NativeBridge.nativeHostFingerprint(h)
if (seen.isNotEmpty()) {
val name = record?.name
?: discovered.firstOrNull { it.host == addr && it.port == port }?.name
?: addr
record = knownHostStore.trust(addr, port, name, seen, paired = requestAccess || record?.paired == true)
pushHosts(); pushKnownHosts()
}
}
if (record != null) {
NativeBridge.nativeHostMgmtPort(h).takeIf { it > 0 }?.let {
knownHostStore.learnMgmtPort(record.address, record.port, it)
}
}
NativeBridge.nativeConsoleSessionPhase(handle, 1, "")
onConnected?.invoke(
ActiveSession(
h,
effective,
clipboardSync = record?.clipboardSync ?: true,
profileName = profile?.name,
hostId = record?.id,
launchedFromLibrary = launchId != null,
libraryProfileId = profileId,
),
)
} else {
val token = NativeBridge.nativeTakeLastError()
NativeBridge.nativeConsoleSessionPhase(
handle, 2, ConnectErrors.connectMessage(token, requestAccess),
)
discovery?.restart()
}
}
}
}
// ---- commands from the console -----------------------------------------------------
private fun drainCommands() {
val arr = runCatching { JSONArray(NativeBridge.nativeConsoleDrainCmds(handle)) }.getOrNull() ?: return
for (i in 0 until arr.length()) {
when (val c = arr.opt(i)) {
is String -> when (c) {
"CancelWake" -> { wakeGen.incrementAndGet(); NativeBridge.nativeConsoleSetWake(handle, "null") }
"Probe" -> { discovery?.restart(); pushHosts() }
}
is JSONObject -> {
c.optJSONObject("FetchLibrary")?.let { fetchLibrary(it, refreshOnly = false) }
c.optJSONObject("RefreshRunning")?.let { fetchLibrary(it, refreshOnly = true) }
c.optJSONObject("Pair")?.let(::pair)
c.optJSONObject("SendLogs")?.let(::sendLogs)
c.optJSONObject("SaveHost")?.let(::saveHost)
c.optJSONObject("UpdateHost")?.let(::updateHost)
c.optJSONObject("ForgetHost")?.let(::forgetHost)
c.optJSONObject("Wake")?.let(::wake)
c.optJSONObject("SetPin")?.let(::setPin)
c.optJSONObject("BindProfile")?.let(::bindProfile)
c.optJSONObject("SetClipboard")?.let(::setClipboard)
c.optJSONObject("OpenPlatformScreen")?.let { onPlatformScreen?.invoke(it.optString("id")) }
c.optJSONObject("PadAction")?.let { onPadAction?.invoke(it.optString("action"), it.optString("pad_key")) }
c.optString("OpenPlatformScreen").takeIf { c.has("OpenPlatformScreen") && c.opt("OpenPlatformScreen") is String }
?.let { onPlatformScreen?.invoke(it) }
}
}
}
}
private fun hostForKey(key: String): KnownHost? {
val primary = key.substringBefore('\u0000')
return knownHostStore.all().firstOrNull { ConsoleJson.rowKey(it.fpHex, it.address, it.port) == primary }
}
private fun saveHost(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port"); val name = c.optString("name")
val existing = knownHostStore.get(addr, port)
if (existing != null) {
if (name.isNotEmpty()) knownHostStore.save(existing.copy(name = name))
} else {
knownHostStore.save(KnownHost(address = addr, port = port, name = name.ifEmpty { addr }, fpHex = "", paired = false))
}
pushHosts(); pushKnownHosts()
}
private fun updateHost(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val name = c.optString("name").trim(); val addr = c.optString("addr"); val port = c.optInt("port")
if (addr != kh.address || port != kh.port) knownHostStore.remove(kh)
knownHostStore.save(kh.copy(name = name.ifEmpty { addr }, address = addr, port = port))
pushHosts(); pushKnownHosts()
}
private fun forgetHost(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
knownHostStore.remove(kh)
appContext?.let { LibraryCache.standard(it.cacheDir).forget(kh.id) }
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::BindProfile` — the host's default binding (`KnownHost.profileId`); null clears. */
private fun bindProfile(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id")
.takeIf { c.has("profile_id") && !c.isNull("profile_id") && it.isNotEmpty() }
knownHostStore.save(kh.copy(profileId = pid))
pushHosts(); pushKnownHosts()
}
/** `ConsoleCmd::SetClipboard` — the per-host clipboard trust toggle. */
private fun setClipboard(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
knownHostStore.save(kh.copy(clipboardSync = c.optBoolean("on")))
pushHosts(); pushKnownHosts()
}
private fun setPin(c: JSONObject) {
val kh = hostForKey(c.optString("key")) ?: return
val pid = c.optString("profile_id"); val pin = c.optBoolean("pin")
val pins = kh.pinnedProfileIds.toMutableList()
if (pin && pid !in pins) pins.add(pid) else if (!pin) pins.remove(pid)
knownHostStore.save(kh.copy(pinnedProfileIds = pins))
pushHosts(); pushKnownHosts()
}
/**
* `ConsoleCmd::SendLogs` the native log ring (`nativeRenderLogs`) posted to this
* paired host's `POST /api/v1/client-logs` over the same mTLS client the library fetch
* uses; the result comes back as a notice, in the desktop console's wording. The header
* mirrors the desktop's identity line (`punktfunk-session <ver> (<os> <arch>) client
* log bundle`).
*/
private fun sendLogs(c: JSONObject) {
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val hostName = c.optString("host_name").ifEmpty { addr }
val id = identity
if (id == null) {
notice("Identity not ready yet — try again in a moment")
return
}
val version = appContext?.let { app ->
runCatching { app.packageManager.getPackageInfo(app.packageName, 0).versionName }.getOrNull()
} ?: "?"
val header = "punktfunk-android $version (android ${android.os.Build.VERSION.RELEASE}; " +
"${android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}) — client log bundle"
ioPool.execute {
val err = runCatching {
val body = NativeBridge.nativeRenderLogs(header)
val client = io.unom.punktfunk.kit.library.mtlsHttpClient(
id.certPem, id.privateKeyPem, addr, fp,
)
val req = Request.Builder()
.url("https://$addr:$mgmt/api/v1/client-logs")
.post(body.toRequestBody("text/plain; charset=utf-8".toMediaType()))
.build()
client.newCall(req).execute().use { resp ->
if (resp.code == 200) "" else "host answered HTTP ${resp.code}"
}
}.getOrElse { it.message ?: "upload failed" }
main.post {
notice(
if (err.isEmpty()) {
"Logs sent to $hostName — download them from its web console's Logs page"
} else {
"Couldn't send logs — $err"
},
)
}
}
}
private fun pair(c: JSONObject) {
val addr = c.optString("addr"); val port = c.optInt("port")
val pin = c.optString("pin"); val name = c.optString("device_name")
val id = identity
if (id == null) {
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairFailed("Identity not ready yet — try again in a moment"))
return
}
val hostName = knownHostStore.get(addr, port)?.name
?: discovered.firstOrNull { it.host == addr && it.port == port }?.name ?: addr
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairBusy())
ioPool.execute {
val fp = runCatching { NativeBridge.nativePair(addr, port, id.certPem, id.privateKeyPem, pin, name) }.getOrDefault("")
main.post {
if (fp.isNotEmpty()) {
knownHostStore.trust(addr, port, hostName, fp, paired = true)
pushHosts(); pushKnownHosts()
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairPaired(fp))
} else {
NativeBridge.nativeConsoleSetPair(handle, ConsoleJson.pairFailed(ConnectErrors.pairMessage(NativeBridge.nativeTakeLastError())))
}
}
}
}
/**
* The wake-and-wait loop (the desktop's `spawn_wake`): resend the magic packet every 6 s,
* probe once a second, 90 s timeout; the console reads `online`/`timed_out` off the status
* and acts (a `then_connect` wake dials from the shell's side once online).
*/
private fun wake(c: JSONObject) {
val key = c.optString("key"); val thenConnect = c.optBoolean("then_connect")
val kh = hostForKey(key) ?: return
if (kh.mac.isEmpty()) return
val gen = wakeGen.incrementAndGet()
val name = kh.name.ifBlank { kh.address }
ioPool.execute {
val started = System.currentTimeMillis()
var lastPacket = 0L
while (wakeGen.get() == gen && handle != 0L) {
val elapsed = ((System.currentTimeMillis() - started) / 1000).toInt()
val timedOut = elapsed >= 90
if (!timedOut && System.currentTimeMillis() - lastPacket >= 6_000) {
NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address)
lastPacket = System.currentTimeMillis()
}
val online = NativeBridge.nativeProbe(kh.address, kh.port, 900) ||
discovered.any { kh.matches(it) }
if (wakeGen.get() != gen) return@execute
NativeBridge.nativeConsoleSetWake(
handle,
ConsoleJson.wakeStatus(key, name, elapsed, timedOut, online, thenConnect),
)
if (online || timedOut) return@execute
Thread.sleep(1000)
}
}
}
/**
* The library pipeline (the desktop's `spawn_fetch`): cached shelf first, wake + retry
* across the boot window when the host has a MAC, then the catalog, the running set and
* the posters each poster fetched over the same mTLS client and pushed as bytes.
*/
private fun fetchLibrary(c: JSONObject, refreshOnly: Boolean) {
val app = appContext ?: return
val addr = c.optString("addr"); val mgmt = c.optInt("mgmt"); val fp = c.optString("fp_hex")
val id = identity
val kh = knownHostStore.all().firstOrNull { it.fpHex.equals(fp, true) && fp.isNotEmpty() }
?: knownHostStore.get(addr, mgmt)
if (refreshOnly) {
if (id == null) return
ioPool.execute {
val up = LibraryClient.fetchRunning(addr, mgmt, id.certPem, id.privateKeyPem, fp)
.filter { it.isUp }.mapNotNull { it.appId }
main.post { if (handle != 0L) NativeBridge.nativeConsoleLibraryRunning(handle, ConsoleJson.stringArray(up)) }
}
return
}
val gen = fetchGen.incrementAndGet()
NativeBridge.nativeConsoleLibraryBegin(handle)
if (id == null) {
NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Couldn't load the library", "Identity not ready yet — try again in a moment", true))
return
}
val cache = LibraryCache.standard(app.cacheDir)
val cacheKey = kh?.id ?: fp.ifEmpty { "$addr:$mgmt" }
ioPool.execute {
val cached = cache.load(cacheKey)?.games?.takeIf { it.isNotEmpty() }
if (cached != null) main.post { if (gen == fetchGen.get()) NativeBridge.nativeConsoleLibraryGames(handle, ConsoleJson.libraryGames(cached), true) }
val macs = kh?.mac.orEmpty()
val waking = macs.isNotEmpty() && settings.autoWakeEnabled
if (waking) NativeBridge.nativeWakeOnLan(macs.joinToString(","), addr)
val attempts = if (waking) 12 else 1
var result: LibraryResult? = null
for (attempt in 0 until attempts) {
if (gen != fetchGen.get()) return@execute
val r = LibraryClient.fetch(addr, mgmt, id.certPem, id.privateKeyPem, fp)
result = r
if (r is LibraryResult.Ok || r is LibraryResult.Unauthorized) break
if (attempt + 1 >= attempts) break
if (attempt % 2 == 1) NativeBridge.nativeWakeOnLan(macs.joinToString(","), addr)
main.post { if (gen == fetchGen.get()) NativeBridge.nativeConsoleLibraryStale(handle, 1) }
Thread.sleep(5_000)
}
if (gen != fetchGen.get()) return@execute
when (val r = result) {
is LibraryResult.Ok -> {
val games = r.games
cache.store(cacheKey, games)
val up = LibraryClient.fetchRunning(addr, mgmt, id.certPem, id.privateKeyPem, fp)
.filter { it.isUp }.mapNotNull { it.appId }
main.post {
if (gen != fetchGen.get()) return@post
NativeBridge.nativeConsoleLibraryGames(handle, ConsoleJson.libraryGames(games), false)
NativeBridge.nativeConsoleLibraryStale(handle, 0)
NativeBridge.nativeConsoleLibraryRunning(handle, ConsoleJson.stringArray(up))
}
for (g in games) {
val candidates = g.art.posterCandidates
if (candidates.isEmpty()) continue
artPool.execute {
if (gen != fetchGen.get()) return@execute
val bytes = fetchArt(candidates, id, addr, fp) ?: return@execute
main.post { if (gen == fetchGen.get() && handle != 0L) NativeBridge.nativeConsoleLibraryArt(handle, g.id, bytes) }
}
}
}
is LibraryResult.Unauthorized -> main.post {
if (gen != fetchGen.get()) return@post
if (cached != null) NativeBridge.nativeConsoleLibraryStale(handle, 2)
else NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Not paired", r.message, false))
}
is LibraryResult.Error -> main.post {
if (gen != fetchGen.get()) return@post
if (cached != null) NativeBridge.nativeConsoleLibraryStale(handle, 2)
else NativeBridge.nativeConsoleLibraryPhase(handle, ConsoleJson.libraryError("Couldn't load the library", r.message, true))
}
null -> {}
}
}
}
/** One poster: the candidates in order, first success wins; the host's art proxy over mTLS. */
private fun fetchArt(candidates: List<String>, id: ClientIdentity, addr: String, fp: String): ByteArray? {
for (url in candidates) {
val client = if (url.contains(addr)) {
runCatching { io.unom.punktfunk.kit.library.mtlsHttpClient(id.certPem, id.privateKeyPem, addr, fp) }.getOrNull() ?: continue
} else artHttp
val bytes = runCatching {
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
if (resp.code == 200) resp.body?.bytes()?.takeIf { it.isNotEmpty() && it.size <= 16 shl 20 } else null
}
}.getOrNull()
if (bytes != null) return bytes
}
return null
}
/** The no-PIN request-access park (≥ the host's approval window) — ConnectScreen's figure. */
private const val REQUEST_ACCESS_TIMEOUT_MS = 185_000
}
@@ -0,0 +1,457 @@
package io.unom.punktfunk.console
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.hardware.usb.UsbManager
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.union
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ActivityCompat
import io.unom.punktfunk.ConsoleLicensesScreen
import io.unom.punktfunk.DS_USB_PERMISSION_ACTION
import io.unom.punktfunk.MainActivity
import io.unom.punktfunk.Settings
import io.unom.punktfunk.SettingsStore
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.kit.Sc2BleLink
import io.unom.punktfunk.rememberConsoleHaptics
import io.unom.punktfunk.testRumble
import kotlin.math.roundToInt
/**
* The gamepad/console UI drawn by the Skia shell (`crates/pf-console-ui`), hosted on a
* `SurfaceView` this composable owns and driven through [SkiaConsole]. Same call shape as the
* Compose `GamepadShell` it replaces (`App.kt` picks one by [SkiaConsole.wanted]).
*
* What lives here is only what needs a composition: the surface lifecycle, the safe-area insets,
* the pad probes (raw pad the shared menu synthesizer, over JNI), the system Back, the
* platform-native sub-screen the console can open (Licences Compose, drawn over the surface;
* Connected controllers is the console's own Skia screen now), and the two intents the app hands
* over on the way in (a deep link, "come back to this shelf").
*/
@Composable
fun SkiaConsoleShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (ActiveSession) -> Unit,
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
reopenLibrary: LibraryReturn? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
val activity = context as? MainActivity
val handle = remember { SkiaConsole.ensure(context, settings) }
val haptics = rememberConsoleHaptics()
// A platform-native screen the console opened over itself (design D7): the console's own
// input is held while it is up, and Back closes it.
var platformScreen by remember { mutableStateOf<String?>(null) }
val currentOnConnected by rememberUpdatedState(onConnected)
val currentOnSettingsChange by rememberUpdatedState(onSettingsChange)
DisposableEffect(handle) {
SkiaConsole.attach(
onConnected = { currentOnConnected(it) },
onSettingsChange = { currentOnSettingsChange(it) },
onQuit = { activity?.moveTaskToBack(true) },
onPlatformScreen = { platformScreen = it },
onPadAction = { action, key -> padAction(activity, action, key) },
onPulse = { pulse ->
when (pulse) {
"move" -> haptics.tick()
"confirm" -> haptics.confirm()
"boundary" -> haptics.boundary()
}
},
)
onDispose { SkiaConsole.detach() }
}
// Settings edited elsewhere (the touch UI shares the store) reach the shell on its next read.
LaunchedEffect(settings) { SkiaConsole.settingsChanged(settings) }
// "Come back to the shelf this game was launched from" — consumed once, on entry.
LaunchedEffect(reopenLibrary) {
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
SkiaConsole.openLibrary(id, pinId)
onReopenLibraryHandled()
}
// A `punktfunk://` link on the way in: consumed once; the bridge dials a known-and-pinned
// host and refuses (with a notice) anything that would need a trust decision.
LaunchedEffect(deepLink) {
val url = deepLink ?: return@LaunchedEffect
onDeepLinkHandled()
SkiaConsole.handleDeepLink(url)
}
// The console owns the whole panel while it fronts the app, exactly like the stream: the
// status bar and the gesture bar are hidden (a swipe shows them transiently). This is both the
// space win AND the safe-area fix — hidden bars report zero insets, so the scroll clips that
// used to end at the visible gesture-bar line now run to the panel edge. Only the display
// cutout stays a real inset. The hide/show itself lives in App.kt (one owner; a per-screen
// `onDispose { show }` fired after the stream's hide during the AnimatedContent cross-fade).
// The safe area, in surface pixels: system bars display cutout — the NP3's landscape punch
// is a SIDE inset, and the console's chrome must stay clear of it (its backdrop need not).
// With the bars hidden above, this is normally just the cutout.
val density = LocalDensity.current
val ld = LocalLayoutDirection.current
val insets = WindowInsets.systemBars.union(WindowInsets.displayCutout)
val left = insets.getLeft(density, ld).toFloat()
val top = insets.getTop(density).toFloat()
val right = insets.getRight(density, ld).toFloat()
val bottom = insets.getBottom(density).toFloat()
// Design-unit scale: TVs take the couch formula (0 = the shell decides: a 4K panel is 2.7×,
// the same 800-unit field as a Deck); a phone or tablet in the hand gets a density FLOOR
// under that formula, so type never shrinks below what the touch UI draws at the same
// density (design D5 — a bare height/800 on a 460 dpi phone lands ~26 % smaller than a Deck).
// The 0.75 is the on-glass tuning knob — raised from 0.6 after a 460 dpi phone (Nothing
// Phone) still read a step too small in the hand: the floor is what sets the phone scale
// (the couch term only wins on tablets and TVs), so this is a phones-only bump.
val tv = remember { io.unom.punktfunk.isTvDevice(context) }
val scale = if (tv) 0f else {
val dm = context.resources.displayMetrics
val couch = minOf(dm.widthPixels, dm.heightPixels) / 800f
maxOf(couch, density.density * 0.75f).coerceIn(0.75f, 3f)
}
LaunchedEffect(handle, left, top, right, bottom, scale) {
if (handle != 0L) NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)
}
// The pad, raw, before MainActivity's B→Back and stick→D-pad synthesis: face buttons and the
// stick/HAT become one MenuSample the shared synthesizer turns into menu events; a TV remote's
// D-pad keys (not SOURCE_GAMEPAD) go in as discrete events; hardware keys as `Key`s.
val padState = remember { PadState() }
val platformUp by rememberUpdatedState(platformScreen != null)
DisposableEffect(handle, activity) {
if (activity == null || handle == 0L) return@DisposableEffect onDispose {}
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
if (platformUp) return@probe false
val down = ev.action == KeyEvent.ACTION_DOWN
if (ev.action != KeyEvent.ACTION_DOWN && ev.action != KeyEvent.ACTION_UP) return@probe false
val fromPad = ev.isFromSource(InputDevice.SOURCE_GAMEPAD)
if (fromPad) {
// The CORRECTED keycode: a pad Android has no key layout for delivers its buttons
// under other buttons' names, so read raw this console answered ✕ with whatever
// sat in BUTTON_A's scancode slot. Same resolution the stream uses — the console
// and the game must not disagree about which button a user pressed.
val code = Gamepad.padKeyCode(ev)
val bit = when (code) {
KeyEvent.KEYCODE_BUTTON_A -> 0
KeyEvent.KEYCODE_BUTTON_B -> 1
KeyEvent.KEYCODE_BUTTON_X -> 2
KeyEvent.KEYCODE_BUTTON_Y -> 3
KeyEvent.KEYCODE_BUTTON_L1 -> 4
KeyEvent.KEYCODE_BUTTON_R1 -> 5
else -> -1
}
if (bit >= 0) {
padState.button(bit, down)
padState.push(handle)
// MainActivity already noted the driving pad (lastPadDeviceId) before this
// probe ran; refresh the chip when the pad behind the buttons changes.
if (padState.deviceId != ev.deviceId) {
padState.deviceId = ev.deviceId
SkiaConsole.padsChanged(ev.device)
}
return@probe true
}
val dbit = when (code) {
KeyEvent.KEYCODE_DPAD_UP -> 0
KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT -> 2
KeyEvent.KEYCODE_DPAD_RIGHT -> 3
else -> -1
}
if (dbit >= 0) {
padState.dpad(dbit, down)
padState.push(handle)
return@probe true
}
if (code == KeyEvent.KEYCODE_BUTTON_SELECT && down && ev.repeatCount == 0) {
NativeBridge.nativeConsoleMenu(handle, 0) // ▲ opens the tile's options on Home
return@probe true
}
return@probe false
}
// A remote / keyboard. D-pad keys and DPAD_CENTER as discrete events with the
// framework's own repeat; the rest as console keys; printable text while editing.
if (!down) {
return@probe when (ev.keyCode) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_ESCAPE, KeyEvent.KEYCODE_TAB, KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_DEL, KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_DOWN -> true
else -> false
}
}
val repeat = ev.repeatCount > 0
when (ev.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> NativeBridge.nativeConsoleMenu(handle, 0)
KeyEvent.KEYCODE_DPAD_DOWN -> NativeBridge.nativeConsoleMenu(handle, 1)
KeyEvent.KEYCODE_DPAD_LEFT -> NativeBridge.nativeConsoleMenu(handle, 2)
KeyEvent.KEYCODE_DPAD_RIGHT -> NativeBridge.nativeConsoleMenu(handle, 3)
KeyEvent.KEYCODE_DPAD_CENTER -> if (!repeat) NativeBridge.nativeConsoleMenu(handle, 4)
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> NativeBridge.nativeConsoleKey(handle, 4, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_SPACE -> NativeBridge.nativeConsoleKey(handle, 5, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_ESCAPE -> NativeBridge.nativeConsoleKey(handle, 6, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_BACK -> if (!repeat) NativeBridge.nativeConsoleMenu(handle, 5)
KeyEvent.KEYCODE_DEL -> NativeBridge.nativeConsoleKey(handle, 7, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_PAGE_UP -> NativeBridge.nativeConsoleKey(handle, 8, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_PAGE_DOWN -> NativeBridge.nativeConsoleKey(handle, 9, ev.isShiftPressed, repeat)
KeyEvent.KEYCODE_TAB -> NativeBridge.nativeConsoleKey(handle, 10, ev.isShiftPressed, repeat)
else -> {
val ch = ev.unicodeChar
if (ch != 0 && !ev.isCtrlPressed && !ev.isAltPressed && ch >= 0x20) {
NativeBridge.nativeConsoleText(handle, String(Character.toChars(ch)))
} else {
return@probe false
}
}
}
true
}
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (platformUp) return@probe false
if (!ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && !ev.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
return@probe false
}
val lx = ev.getAxisValue(MotionEvent.AXIS_X)
val ly = ev.getAxisValue(MotionEvent.AXIS_Y)
val hx = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
val hy = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
padState.stick(lx, ly)
padState.hat(hx, hy)
padState.push(handle)
true
}
val probes = MainActivity.PadProbes(keyProbe, motionProbe)
activity.pushPadProbes(probes)
SkiaConsole.padsChanged(Gamepad.firstPad())
onDispose {
// Remove OUR claim only — a platform screen pushed over us keeps its own, and when it
// pops, this one resurfaces (the stack is what fixed the pad dying after Controllers).
activity.removePadProbes(probes)
padState.reset()
if (handle != 0L) padState.push(handle)
}
}
// The system Back (gesture or key) is the console's B; at its root the shell raises Quit.
BackHandler(enabled = platformScreen == null) {
if (handle != 0L) NativeBridge.nativeConsoleMenu(handle, 5)
}
Box(Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
SurfaceView(ctx).apply {
// The console draws opaque, edge to edge; Compose overlays sit above it.
setZOrderMediaOverlay(false)
isFocusable = false
isFocusableInTouchMode = false
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(h: SurfaceHolder) {
if (handle != 0L) NativeBridge.nativeConsoleSurfaceCreated(handle, h.surface)
}
override fun surfaceChanged(h: SurfaceHolder, format: Int, width: Int, height: Int) {
if (handle != 0L) NativeBridge.nativeConsoleSurfaceChanged(handle)
}
override fun surfaceDestroyed(h: SurfaceHolder) {
if (handle != 0L) NativeBridge.nativeConsoleSurfaceDestroyed(handle)
}
})
// Touch → the console's pointer (surface pixels): the escape hatch when no
// pad is attached, and the natural way to press a legend hint on a phone.
// A finger's down is kind 6 (the shell defers it so a swipe scrolls); a
// mouse — which Android delivers through this same listener — keeps kind 1
// and acts on the press, as a mouse should.
setOnTouchListener { v, ev ->
if (handle == 0L) return@setOnTouchListener false
val kind = when (ev.actionMasked) {
MotionEvent.ACTION_DOWN ->
if (ev.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) 1 else 6
MotionEvent.ACTION_MOVE -> 0
MotionEvent.ACTION_UP -> 2
MotionEvent.ACTION_CANCEL -> 5
else -> return@setOnTouchListener false
}
NativeBridge.nativeConsolePointer(handle, kind, ev.x, ev.y, 0f)
if (ev.actionMasked == MotionEvent.ACTION_UP) v.performClick()
true
}
setOnGenericMotionListener { _, ev ->
if (handle != 0L && ev.actionMasked == MotionEvent.ACTION_SCROLL &&
ev.isFromSource(InputDevice.SOURCE_CLASS_POINTER)
) {
NativeBridge.nativeConsolePointer(handle, 4, ev.x, ev.y, ev.getAxisValue(MotionEvent.AXIS_VSCROLL))
true
} else false
}
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
},
)
when (platformScreen) {
"licenses" -> ConsoleLicensesScreen(onBack = { platformScreen = null }, navActive = true)
}
}
}
/**
* A `ConsoleCmd::PadAction` from the console's Connected-controllers screen the handful of
* things only the platform can do: a rumble pulse on the real [InputDevice], the USB/Bluetooth
* grant dialogs, the DualSense pad-audio self test. The touch Controllers screen keeps its own
* buttons for the same actions; both routes end in the same helpers ([testRumble], the grant
* intents, `nativePadAudioSelfTest`), so the support answer cannot drift between interfaces.
* Runs on the main thread (the command drain lives there); results ride [SkiaConsole.notice].
*/
private fun padAction(activity: MainActivity?, action: String, padKey: String) {
if (activity == null) return
val settings = SettingsStore(activity).load()
val usb = activity.getSystemService(Context.USB_SERVICE) as UsbManager
when (action) {
"rumble" ->
Gamepad.pads()
.firstOrNull { "${it.vendorId}:${it.productId}:${it.name}" == padKey }
?.let(::testRumble)
"sc2_bluetooth" -> when {
!settings.sc2Capture ->
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
Sc2BleLink.permissionGranted(activity) ->
SkiaConsole.notice("Bluetooth access is already granted.")
// The system dialog pauses the activity; onResume re-probes and engages the capture,
// the same way the menu-time auto-ask completes.
else -> Sc2BleLink.CONNECT_PERMISSION?.let {
ActivityCompat.requestPermissions(activity, arrayOf(it), 5)
}
}
"sc2_usb" ->
if (!settings.sc2Capture) {
SkiaConsole.notice("Enable \"Steam Controller 2 passthrough\" in Settings first.")
} else {
// Asks for the USB grant when one is missing and engages the capture on it.
activity.startSc2MenuNav(forceAsk = true)
}
"ds_usb" -> {
val dev = usb.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
when {
!settings.dsCapture ->
SkiaConsole.notice(
"Enable \"DualSense / DualShock passthrough (USB)\" in Settings first.",
)
dev == null -> SkiaConsole.notice("No wired DualSense or DualShock 4 detected.")
usb.hasPermission(dev) -> SkiaConsole.notice("USB access is already granted.")
else -> usb.requestPermission(
dev,
PendingIntent.getBroadcast(
activity, 3, // requestCode 3 — shared with the touch card's button
Intent(DS_USB_PERMISSION_ACTION).setPackage(activity.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
"ds_haptics" -> {
val dev = usb.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
when {
dev == null -> SkiaConsole.notice("No wired DualSense detected.")
DsDevice.modelFor(dev.productId) == DsDevice.Model.DUALSHOCK4 ->
SkiaConsole.notice("The DualShock 4 has no haptics audio device.")
!usb.hasPermission(dev) -> SkiaConsole.notice("Grant USB access first.")
else -> Thread({
// Its OWN connection: the renderer's descriptor must never be shared with
// another transfer engine, and that applies to this test as much as to the
// real path (same rule as the touch card's test).
val conn = runCatching { usb.openDevice(dev) }.getOrNull()
val fd = conn?.fileDescriptor ?: -1
val r = if (fd >= 0) NativeBridge.nativePadAudioSelfTest(fd, 3, 60) else -1
conn?.close()
SkiaConsole.notice(
when {
r > 0 -> "Haptics test passed — $r frames to the pad."
r == -1 ->
"Could not open the pad's audio interface. Some kernels " +
"refuse it; the pad still works normally."
r == -2 -> "The audio stream stopped part-way."
else -> "The stream opened but no audio reached the pad."
},
)
}, "pf-pad-selftest-console").start()
}
}
}
}
/** The raw pad as one `MenuSample`, pushed whenever any part of it changes. */
private class PadState {
var deviceId = -1
private var buttons = 0
private var dpad = 0
private var lx = 0
private var ly = 0
private var last: IntArray? = null
fun button(bit: Int, down: Boolean) {
buttons = if (down) buttons or (1 shl bit) else buttons and (1 shl bit).inv()
}
fun dpad(bit: Int, down: Boolean) {
dpad = if (down) dpad or (1 shl bit) else dpad and (1 shl bit).inv()
}
fun stick(x: Float, y: Float) {
lx = (x.coerceIn(-1f, 1f) * 32767f).roundToInt()
ly = (y.coerceIn(-1f, 1f) * 32767f).roundToInt()
}
/** The HAT is the D-pad on most pads' motion path (±1 per axis). */
fun hat(x: Float, y: Float) {
dpad(2, x <= -0.5f); dpad(3, x >= 0.5f); dpad(0, y <= -0.5f); dpad(1, y >= 0.5f)
}
fun reset() {
buttons = 0; dpad = 0; lx = 0; ly = 0
}
fun push(handle: Long) {
val now = intArrayOf(buttons, lx, ly, dpad)
if (last?.contentEquals(now) == true) return
last = now
NativeBridge.nativeConsolePadSample(handle, buttons, lx, ly, dpad)
}
}
@@ -0,0 +1,73 @@
package io.unom.punktfunk
import io.unom.punktfunk.console.ConsoleJson
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The Android-only console settings ride `trust::Settings::extra`, which is `#[serde(flatten)]`:
* they are TOP-LEVEL keys of the settings document, beside `width` and `codec`.
*
* They were written and read nested under an `"extra"` object instead. Serde put that whole
* object into the map under the literal key `"extra"`, so no console row ever found
* `android.gamepad_ui_enabled` and the value the console saved came back to Kotlin as the one
* Kotlin had just sent. On glass that was a "Controller-optimized UI" switch you could turn off
* with nothing happening: the console stayed up, because the setting never moved.
*/
class ConsoleSettingsExtraTest {
@Test
fun androidKeysAreWrittenFlat() {
val j = ConsoleJson.settings(Settings(gamepadUiEnabled = false, lowLatencyMode = false), null)
assertTrue("the console reads this key at the top level", j.has("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.gamepad_ui_enabled"))
assertFalse(j.getBoolean("android.low_latency"))
assertFalse("a nested wrapper is what serde swallows whole", j.has("extra"))
}
/** A store written by the nesting build must not keep echoing its dead wrapper. */
@Test
fun aStaleNestedWrapperIsDropped() {
val base = JSONObject().put(
"extra",
JSONObject().put("android.gamepad_ui_enabled", true),
)
assertFalse(ConsoleJson.settings(Settings(gamepadUiEnabled = false), base).has("extra"))
}
@Test
fun theConsolesOwnSaveIsReadBack() {
val saved = JSONObject()
.put("android.gamepad_ui_enabled", false)
.put("android.gamepad_ui_mode", GAMEPAD_UI_ALWAYS)
.put("android.ds_capture", false)
val next = ConsoleJson.applySettings(Settings(), saved)
assertFalse("turning the console off must reach the store", next.gamepadUiEnabled)
assertEquals(GAMEPAD_UI_ALWAYS, next.gamepadUiMode)
assertFalse(next.dsCapture)
}
/** Both halves against each other — the shape only holds if they agree. */
@Test
fun theRoundTripKeepsEveryAndroidRow() {
val want = Settings(
gamepadUiEnabled = false,
gamepadUiMode = GAMEPAD_UI_ALWAYS,
lowLatencyMode = false,
rumbleOnPhone = true,
gyroOnPhone = true,
sc2Capture = false,
dsCapture = false,
)
val got = ConsoleJson.applySettings(Settings(), ConsoleJson.settings(want, null))
assertEquals(want.gamepadUiEnabled, got.gamepadUiEnabled)
assertEquals(want.gamepadUiMode, got.gamepadUiMode)
assertEquals(want.lowLatencyMode, got.lowLatencyMode)
assertEquals(want.rumbleOnPhone, got.rumbleOnPhone)
assertEquals(want.gyroOnPhone, got.gyroOnPhone)
assertEquals(want.sc2Capture, got.sc2Capture)
assertEquals(want.dsCapture, got.dsCapture)
}
}
@@ -1,107 +0,0 @@
package io.unom.punktfunk
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The console route to the two sub-screens, driven through the REAL settings screen the rows
* themselves are pinned by `ConsoleSubScreenRowsTest`; what needs the Compose runtime is the trip:
* that a press on the row reaches the shell, and that coming back lands where you left rather than
* at the top of the first section (the shell's `AnimatedContent` discards a screen's state the
* moment it stops being the target, so the place has to travel out and back).
*
* Rows are activated by TAP for the same reason `GamepadSettingsLayoutTest` does it: the pad path
* needs a `MainActivity` for its probes, and both routes end in the same `activate`.
*
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36 while
* the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
class ConsoleSubScreenRoutesTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun openingTheControllersRowNavigatesAndReportsWhereItWas() {
var opened = 0
var place: GpSettingsPlace? = null
compose.setContent {
GamepadSettingsScreen(
initial = Settings(),
onChange = {},
onBack = {},
onOpenControllers = { opened++ },
// Entering as if we had just come back from it, which is also what puts the cursor
// on the row — so a single tap ACTIVATES rather than merely focusing.
resume = GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
onPlace = { place = it },
)
}
compose.waitForIdle()
compose.onNodeWithText("Connected controllers").performClick()
compose.waitForIdle()
assertEquals("the console never reached the diagnostics screen", 1, opened)
assertEquals(
"the place has to leave before the row does — this screen is gone the next frame",
GpSettingsPlace(GpTab.CONTROLLER, "controllers"),
place,
)
}
/**
* Back from a sub-screen lands on the section it was opened from, with the row on screen. The
* cursor is restored by row ID rather than index, so it survives a section whose length follows
* the hardware.
*/
@Test
fun comingBackFromTheNoticesLandsOnTheRowThatOpenedThem() {
compose.setContent {
GamepadSettingsScreen(
initial = Settings(),
onChange = {},
onBack = {},
resume = GpSettingsPlace(GpTab.INTERFACE, "licenses"),
)
}
compose.waitForIdle()
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
// Not back at the top of the first section — "Resolution" leads the Stream tab, which is
// where a screen that forgot its place would be.
compose.onNodeWithText("Resolution").assertDoesNotExist()
// And the legend describes THIS row's A. It said the literal "Pin to hosts" on every
// non-adjustable row back when profiles were the only ones.
compose.onNodeWithText("Open").assertIsDisplayed()
compose.onNodeWithText("Pin to hosts").assertDoesNotExist()
}
/**
* The notices screen stands on its own on the console's field: no Scaffold or Surface above it
* (the shell has neither), its own backdrop, and a legend that says how to leave. Composing it
* is most of the assertion a screen that only ever ran inside the touch Scaffold takes its
* content colour from one.
*/
@Test
fun theConsoleNoticesScreenStandsOnItsOwn() {
compose.setContent { ConsoleLicensesScreen(onBack = {}) }
compose.waitForIdle()
compose.onNodeWithText("Open-source licenses").assertIsDisplayed()
compose.onNodeWithText("Scroll").assertIsDisplayed()
compose.onNodeWithText("Close").assertIsDisplayed()
}
}
@@ -1,117 +0,0 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The console's route to the two screens that were reachable from touch only the
* connected-controllers diagnostics and the open-source notices.
*
* "Touch only" reads as a minor gap on a phone and is a dead end on a TV box, where the console IS
* the interface: there is no touch UI to fall back to, so a screen with no console row could not be
* opened at all. These pin the rows themselves; `ConsoleSubScreenRoutesTest` drives the real screen.
*/
class ConsoleSubScreenRowsTest {
private fun rows(
forwarding: Boolean = true,
version: String = "1.2.3",
controllers: () -> Unit = {},
licenses: () -> Unit = {},
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
appVersion = version,
openControllers = controllers,
openLicenses = licenses,
) {}
private fun row(rows: List<GpRow>, id: String): GpRow = rows.first { it.id == id }
@Test
fun `the controllers row opens the diagnostics view from the controller section`() {
var opened = 0
val r = row(rows(controllers = { opened++ }), "controllers")
assertEquals(GpTab.CONTROLLER, r.tab)
assertEquals("Connected controllers", r.label)
r.activate()
assertEquals(1, opened)
}
/**
* It must NOT follow the master forwarding switch, unlike every other row in its section: the
* screen it opens is what you reach for precisely when forwarding looks broken, and a diagnostic
* that dims itself when the thing it diagnoses is off is worse than no diagnostic.
*/
@Test
fun `the controllers row stays live with forwarding off`() {
val off = rows(forwarding = false)
assertTrue(row(off, "controllers").enabled)
assertNotNull(liveRow(off, off.indexOfFirst { it.id == "controllers" }))
// Its neighbours in the section still dim, so this is a deliberate exemption and not a
// forgotten `enabled =`.
assertFalse(row(off, "sc2").enabled)
}
@Test
fun `the about row opens the notices and states the installed version`() {
var opened = 0
val r = row(rows(version = "0.27.0", licenses = { opened++ }), "licenses")
assertEquals(GpTab.INTERFACE, r.tab)
assertEquals("About", r.header)
// The version rides in the value slot — on a TV this row is the whole About page.
assertEquals("0.27.0", r.value)
r.activate()
assertEquals(1, opened)
}
/** Both navigate; neither holds a value, so left/right must be refused rather than silently eaten. */
@Test
fun `neither row steps a value`() {
val all = rows()
for (id in listOf("controllers", "licenses")) {
val r = row(all, id)
assertFalse("$id should draw no chevrons", r.adjustable)
assertFalse("$id must refuse a step", r.adjust(1))
assertFalse("$id must refuse a step", r.adjust(-1))
}
}
/**
* The legend follows the ROW. It used to say the literal "Pin to hosts" on every non-adjustable
* row, because a profile row was the only kind there was so the moment another one existed,
* A on it was advertised as pinning something.
*/
@Test
fun `an action row advertises what A actually does`() {
val all = rows()
assertEquals("Open", row(all, "controllers").actionHint)
assertEquals("Open", row(all, "licenses").actionHint)
val profiles = buildProfileRows(listOf(newProfile("Work")), emptyList(), tv = false) {}
assertEquals("Pin to hosts", profiles.first().actionHint)
}
/**
* The scroll geometry both console sub-screens share. A wall of text has no focusable rows for
* Compose to keep visible, so these screens move the scroll state themselves and how far one
* press travels is the whole of their feel.
*/
@Test
fun `a page overlaps what you were reading and a step is shorter still`() {
val viewport = 1000f
val page = consoleScrollDelta(viewport, page = true, dir = 1)
val step = consoleScrollDelta(viewport, page = false, dir = 1)
assertTrue("a page that skips a whole screenful loses your place", page < viewport)
assertTrue("a page has to be worth pressing", page > viewport / 2f)
assertTrue("a D-pad step must be shorter than a shoulder page", step > 0f && step < page)
assertEquals("the other direction is the other way", -page, consoleScrollDelta(viewport, true, -1), 0.001f)
// Before the first layout there is no viewport: a press then moves nothing, rather than
// scrolling by a fraction of zero and reading as a dead button on the way in.
assertEquals(0f, consoleScrollDelta(0f, page = true, dir = 1), 0f)
}
}
@@ -118,21 +118,8 @@ class ConsoleVectorsTest {
}
}
/**
* The section names, in order. The desktop console carries one tab this client does not
* Input, which holds touch mode, mouse, invert-scroll and shortcuts: desktop-host settings
* with nothing to set on a phone or a TV. The vectors flag it `desktop_only` rather than
* leaving it out, so neither side has to red the other to be right.
*/
@Test
fun tabNamesMatchTheSharedVectors() {
val tabs = vectors.getJSONArray("tabs")
val want = (0 until tabs.length())
.map { tabs.getJSONObject(it) }
.filterNot { it.optBoolean("desktop_only", false) }
.map { it.getString("name") }
assertEquals("console settings tabs", want, GpTab.entries.map { it.title })
}
// The tab-names assertion moved with the tabs: the Skia shell renders them, and its Rust twin
// (`tab_names_match_the_shared_vectors` in pf-console-ui) pins them against the same file.
/**
* The screen-transition contract. The easing is sampled rather than compared as Bézier
@@ -136,46 +136,9 @@ class GamepadPaletteTest {
assertTrue(light.shadeScale < 0.5f)
}
/**
* Every settings row lands in exactly one tab a row missing from the tab map is a setting
* that became unreachable on a TV, which is precisely what this screen exists to prevent.
*/
@Test
fun everySettingsRowHasATab() {
val rows = buildSettingsRows(
Settings(), hasBodyVibrator = true, hasGyroscope = true, av1Capable = true,
) {}
assertTrue(rows.isNotEmpty())
assertEquals(rows.size, rows.map { it.id }.toSet().size)
// Profiles is built separately (from the catalog), so no settings row claims it.
assertTrue(rows.none { it.tab == GpTab.PROFILES })
for (t in listOf(GpTab.STREAM, GpTab.VIDEO, GpTab.AUDIO, GpTab.CONTROLLER, GpTab.INTERFACE)) {
assertTrue("$t is empty", rows.any { it.tab == t })
}
}
/** The Background row steps the shared `ui_palette` key and wraps on A, like every choice row. */
@Test
fun backgroundRowStepsTheSharedKey() {
var s = Settings()
fun rows() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }
fun palette() = rows().first { it.id == "palette" }
assertEquals("violet", s.uiPalette)
assertEquals("Violet", palette().value)
assertTrue("already the first = thud", !palette().adjust(-1))
assertTrue(palette().adjust(1))
assertEquals(GamepadPalette.ALL[1].id, s.uiPalette)
// A from the last entry wraps home.
s = s.copy(uiPalette = GamepadPalette.ALL.last().id)
palette().activate()
assertEquals("violet", s.uiPalette)
// A store written by a newer client shows the palette that is actually drawing.
s = s.copy(uiPalette = "chartreuse")
assertEquals("Violet", palette().value)
}
// The settings-rows tests that lived here pinned the Compose console's row
// catalog (`buildSettingsRows`). That console is gone — the shared Skia shell owns the rows
// now, pinned in Rust (`pf-console-ui/src/screens/settings.rs`).
}
@@ -1,111 +0,0 @@
package io.unom.punktfunk
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.Dp
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The console settings list must not MOVE under the cursor. This is the regression net for the
* layout instability the visual refresh fixed, and it needs the real Compose runtime because the
* bug was entirely a layout one every value the model held was correct throughout.
*
* What used to happen: the focused row unfolded its description in place
* (`AnimatedVisibility` + `expandVertically`), so every step of the cursor shrank one row and grew
* another and shifted every row below the focus point on a list that is simultaneously being
* scrolled to keep the focused row visible, whose target therefore moved mid-animation. The
* description now renders in the screen's floating `ConsoleDetailBand`, which is an overlay and
* cannot displace anything. Sideways, the value's `AnimatedContent` animated its own WIDTH on every
* step, walking the chevron and the label's right edge back and forth.
*
* Focus is moved by TAP here rather than by pad: the pad path needs a `MainActivity` for its input
* probes, and the screen routes both to the same `focus` state the geometry under test is the
* same either way.
*
* `sdk = [36]` for the reason every Robolectric test here pins it: android-all jars stop at 36
* while the app compiles against 37.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w360dp-h800dp-xxhdpi")
class GamepadSettingsLayoutTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
private fun settings() {
compose.setContent {
GamepadSettingsScreen(initial = Settings(), onChange = {}, onBack = {})
}
}
/** A Dp compared at hairline tolerance — a rounding difference is not a layout shift. */
private fun assertSame(what: String, expected: Dp, actual: Dp) {
assertEquals(what, expected.value.toDouble(), actual.value.toDouble(), 0.5)
}
/**
* Moving the cursor down the list leaves every OTHER row exactly where it was. The rows below
* the new focus are the ones the old in-row detail pushed around, so they are the assertion
* that matters; the row above proves the shrink half.
*/
@Test
fun focusingARowMovesNoOtherRow() {
settings()
// Entry focus is the first row (Resolution), so "Refresh rate" starts unfocused and
// "Compositor" sits below both candidates.
val refreshBefore = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
val compositorBefore = compose.onNodeWithText("Compositor").getBoundsInRoot()
// One tap on an unfocused row focuses it (a second would activate it — see the screen).
compose.onNodeWithText("Bitrate").performClick()
compose.waitForIdle()
val refreshAfter = compose.onNodeWithText("Refresh rate").getBoundsInRoot()
val compositorAfter = compose.onNodeWithText("Compositor").getBoundsInRoot()
assertSame("row above the cursor moved", refreshBefore.top, refreshAfter.top)
assertSame("row below the cursor moved", compositorBefore.top, compositorAfter.top)
}
/**
* Stepping a value leaves the row's own geometry alone. The label's right edge is the probe:
* it is what the widening value slot used to shove, and it is stable for any value that fits
* the slot (which every shipped Bitrate label does).
*/
@Test
fun steppingAValueMovesNoLabel() {
settings()
compose.onNodeWithText("Bitrate").performClick() // focus it
compose.waitForIdle()
val labelBefore = compose.onNodeWithText("Bitrate").getBoundsInRoot()
compose.onNodeWithText("Bitrate").performClick() // now activates → cycles the value
compose.waitForIdle()
val labelAfter = compose.onNodeWithText("Bitrate").getBoundsInRoot()
assertSame("label moved sideways under a value step", labelBefore.left, labelAfter.left)
assertSame("label moved sideways under a value step", labelBefore.right, labelAfter.right)
assertSame("row changed height under a value step", labelBefore.top, labelAfter.top)
}
/**
* The focused row's description is on screen in the floating band, not inside the row. Proves
* the detail did not simply get dropped when it left the row: it is still what the cursor
* explains itself with.
*/
@Test
fun theFocusedRowsDetailIsShown() {
settings()
compose.onNodeWithText("Refresh rate").performClick()
compose.waitForIdle()
compose.onNodeWithText("Frame rate the host renders and streams at.").assertExists()
}
}
@@ -1,141 +0,0 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The controller-navigable settings rows: what the master forwarding switch governs, and that a
* governed row is inert rather than merely dim.
*
* The touch settings and the desktop console have carried this relationship for a while (`enabled =
* s.gamepadForwarding` / `RowSpec.enabled`); this screen dimmed nothing and stepped everything, so
* these tests pin both halves the flag AND the refusal to write.
*/
class GamepadSettingsRowsTest {
/** Rows for a given forwarding state, capturing whatever a row writes back. */
private fun rows(
forwarding: Boolean,
sink: MutableList<Settings> = mutableListOf(),
): List<GpRow> = buildSettingsRows(
Settings(gamepadForwarding = forwarding),
hasBodyVibrator = true,
hasGyroscope = true,
av1Capable = true,
) { sink += it }
private fun row(rows: List<GpRow>, id: String): GpRow =
rows.first { it.id == id }
/** Every row that only means something while a controller is actually being forwarded. */
private val governed = listOf("padType", "systemButtons", "guideGesture", "sc2", "dsCapture")
@Test
fun `forwarding off dims every row that depends on it`() {
val off = rows(forwarding = false)
for (id in governed) {
assertFalse("$id should be dimmed with forwarding off", row(off, id).enabled)
}
// The master switch itself stays live — otherwise it could never be turned back on.
assertTrue(row(off, "padForward").enabled)
}
@Test
fun `forwarding on leaves them all live`() {
val on = rows(forwarding = true)
for (id in governed) {
assertTrue("$id should be live with forwarding on", row(on, id).enabled)
}
}
@Test
fun `a dimmed row is inert - liveRow withholds it and nothing is written`() {
val writes = mutableListOf<Settings>()
val off = rows(forwarding = false, sink = writes)
for (id in governed) {
val i = off.indexOfFirst { it.id == id }
assertNull("$id must not be reachable while dimmed", liveRow(off, i))
// What the screen actually does on left/right/A — the whole point is that it no-ops.
liveRow(off, i)?.adjust(1)
liveRow(off, i)?.adjust(-1)
liveRow(off, i)?.activate()
}
assertEquals("a dimmed row wrote a setting", emptyList<Settings>(), writes)
}
@Test
fun `the same rows do write once forwarding is on`() {
val writes = mutableListOf<Settings>()
val on = rows(forwarding = true, sink = writes)
val i = on.indexOfFirst { it.id == "sc2" }
assertNotNull(liveRow(on, i))
liveRow(on, i)?.activate()
assertEquals(1, writes.size)
assertFalse("activate flips the toggle", writes[0].sc2Capture)
}
/**
* R18: the Sony passthrough toggle the touch settings have always had. It matters most exactly
* where this screen is the only one reachable a TV box has no touch interface to fall back to.
*/
@Test
fun `the DualSense passthrough toggle is present, next to its SC2 twin`() {
val on = rows(forwarding = true)
val ids = on.map { it.id }
assertTrue("dsCapture row is missing", "dsCapture" in ids)
assertEquals(
"the two passthrough rows belong side by side",
ids.indexOf("sc2") + 1,
ids.indexOf("dsCapture"),
)
// Drawn as a switch, and reading the persisted default.
assertEquals(true, row(on, "dsCapture").toggled)
}
/**
* The activation-mode row is a sub-setting of the Controller-optimized UI switch, so it is
* OFFERED only while that switch is on hidden rather than dimmed, because with the switch
* off this whole screen is about to be replaced by the touch UI and a dimmed row there would
* be one last thing to step past on the way out.
*/
@Test
fun `the activation-mode row follows the switch it belongs to`() {
fun ids(enabled: Boolean) = buildSettingsRows(
Settings(gamepadUiEnabled = enabled),
hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) {}.map { it.id }
val on = ids(enabled = true)
assertTrue("the mode row is missing", "gamepadUIMode" in on)
assertEquals(
"the mode belongs directly under the switch it qualifies",
on.indexOf("gamepadUI") + 1,
on.indexOf("gamepadUIMode"),
)
val off = ids(enabled = false)
assertFalse("the mode row must not outlive its switch", "gamepadUIMode" in off)
assertTrue("the switch itself stays, or it could never be turned back on", "gamepadUI" in off)
}
/** Stepping the mode row writes the shared `gamepad_ui_mode` value, and wraps on A. */
@Test
fun `the activation-mode row steps the shared key`() {
var s = Settings()
fun mode() = buildSettingsRows(
s, hasBodyVibrator = false, hasGyroscope = false, av1Capable = false,
) { s = it }.first { it.id == "gamepadUIMode" }
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
assertEquals("With a controller", mode().value)
assertFalse("already the first = thud", mode().adjust(-1))
assertTrue(mode().adjust(1))
assertEquals(GAMEPAD_UI_ALWAYS, s.gamepadUiMode)
// A from the last entry wraps home.
mode().activate()
assertEquals(GAMEPAD_UI_WHEN_CONNECTED, s.gamepadUiMode)
}
}
@@ -1,210 +0,0 @@
package io.unom.punktfunk
import androidx.compose.ui.graphics.Color
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The console home's tile list ([buildHomeTiles]). Pure JVM the carousel itself needs the live
* JNI core to compose, so its ORDER and what each tile claims had no cover at all until now, and
* both are exactly the kind of thing that survives a refactor looking fine and behaving wrong.
*
* Run: `./gradlew :app:testDebugUnitTest --tests 'io.unom.punktfunk.HomeTilesTest'`.
*/
class HomeTilesTest {
private fun host(
name: String,
address: String,
fp: String = "",
profileId: String? = null,
pins: List<String> = emptyList(),
) = KnownHost(
address = address,
port = 9777,
name = name,
fpHex = fp,
paired = true,
id = "id-$name",
profileId = profileId,
pinnedProfileIds = pins,
)
private fun advert(name: String, address: String, fp: String? = null) = DiscoveredHost(
key = "$address:9777",
name = name,
host = address,
port = 9777,
fingerprint = fp,
)
private val work = StreamProfile(id = "p-work", name = "Work", accent = "#3B82F6")
private val travel = StreamProfile(id = "p-travel", name = "Travel")
/** The builder with nothing plugged in — every list empty, every callback a no-op. */
private fun tiles(
savedHosts: List<KnownHost> = emptyList(),
profiles: List<StreamProfile> = emptyList(),
pins: Map<String, List<StreamProfile>> = emptyMap(),
discoveredUnsaved: List<DiscoveredHost> = emptyList(),
online: Set<String> = emptySet(),
onConnect: (KnownHost, String?) -> Unit = { _, _ -> },
onConnectDiscovered: (DiscoveredHost) -> Unit = {},
onAddHost: () -> Unit = {},
) = buildHomeTiles(
savedHosts = savedHosts,
profiles = profiles,
pinsFor = { kh -> pins[kh.id].orEmpty() },
discoveredUnsaved = discoveredUnsaved,
isOnline = { it.name in online },
onConnect = onConnect,
onConnectDiscovered = onConnectDiscovered,
onAddHost = onAddHost,
)
/**
* A pin belongs to the host above it. Ordering is the whole affordance: on a controller a pin is
* reached by walking one tile past its host, and a builder that grouped all the pins at the end
* would still LOOK right in a screenshot of any single tile.
*/
@Test
fun pinnedCardsFollowTheirOwnHost() {
val living = host("living", "192.168.1.42", pins = listOf(work.id, travel.id))
val studio = host("studio", "192.168.1.61", pins = listOf(work.id))
val ids = tiles(
savedHosts = listOf(living, studio),
profiles = listOf(work, travel),
pins = mapOf(living.id to listOf(work, travel), studio.id to listOf(work)),
).map { it.id }
assertEquals(
listOf(
"saved-id-living",
"pin-id-living-p-work",
"pin-id-living-p-travel",
"saved-id-studio",
"pin-id-studio-p-work",
"add",
),
ids,
)
}
/** Add Host is the last tile, always — including on a device with nothing saved or seen. */
@Test
fun theAddTileIsAlwaysLast() {
val empty = tiles()
assertEquals(listOf("add"), empty.map { it.id })
assertTrue(empty.single().isAdd)
val populated = tiles(
savedHosts = listOf(host("living", "192.168.1.42")),
discoveredUnsaved = listOf(advert("studio", "192.168.1.61")),
)
assertEquals(listOf("saved-id-living", "disc-192.168.1.61:9777", "add"), populated.map { it.id })
assertTrue(populated.last().isAdd)
// The Add tile is not a host: no library, no options menu, nothing to wake.
assertNull(populated.last().knownHost)
}
/**
* A host that is both saved and advertising appears ONCE. The de-dupe is the caller's
* ([KnownHost.matches], which the screen applies before handing the list over) checked here
* because the rule that matters is the fingerprint one: a host that came back on a new DHCP
* address is the same machine, and matching on address alone would offer it a second time as a
* stranger, next to the record that already holds its trust.
*/
@Test
fun aSavedHostSeenOnTheNetworkIsNotListedTwice() {
val fp = "ab12cd34"
val living = host("living", "192.168.1.42", fp = fp)
// Same host, new address after a cold boot, plus a genuine stranger.
val adverts = listOf(advert("living", "192.168.1.77", fp = fp), advert("stranger", "192.168.1.99"))
val unsaved = adverts.filter { dh -> listOf(living).none { it.matches(dh) } }
val ids = tiles(savedHosts = listOf(living), discoveredUnsaved = unsaved).map { it.id }
assertEquals(listOf("saved-id-living", "disc-192.168.1.99:9777", "add"), ids)
}
/**
* The chip says which profile a press will connect with the host's binding on its own tile,
* the pinned profile on a pin tile. The console cannot EDIT profiles, so this claim is the only
* thing standing between a user and a stream with settings they didn't choose.
*/
@Test
fun theChipNamesTheProfileThePressWillUse() {
val living = host("living", "192.168.1.42", profileId = work.id, pins = listOf(travel.id))
val result = tiles(
savedHosts = listOf(living),
profiles = listOf(work, travel),
pins = mapOf(living.id to listOf(travel)),
)
val own = result[0]
assertEquals("Work", own.profileName)
assertEquals(Color(0xFF3B82F6), own.profileAccent)
assertNull(own.pinnedProfileId)
val pin = result[1]
assertEquals("Travel", pin.profileName)
assertEquals(travel.id, pin.pinnedProfileId)
// Travel set no accent: a chip with no colour, not a crash and not a stray default.
assertNull(pin.profileAccent)
// A binding whose profile was deleted resolves to nothing — the tile stays silent rather
// than naming an id that resolves to nobody.
val dangling = tiles(savedHosts = listOf(host("ghost", "10.0.0.5", profileId = "p-gone")))
assertNull(dangling[0].profileName)
}
/** Both address and the subtitle: a pin card says where it points, like every other card. */
@Test
fun everySavedTileSaysWhereItPoints() {
val living = host("living", "192.168.1.42", pins = listOf(work.id))
val result = tiles(
savedHosts = listOf(living),
profiles = listOf(work),
pins = mapOf(living.id to listOf(work)),
online = setOf("living"),
)
result.take(2).forEach {
assertEquals("192.168.1.42:9777", it.subtitle)
assertEquals("living", it.title)
assertTrue(it.filled)
assertTrue(it.online)
assertTrue(it.paired)
assertNotNull(it.knownHost)
}
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
// the pinned profile — the library is a way to start a card, not a host-level action.
assertTrue(result[0].hasLibrary)
assertTrue(result[1].hasLibrary)
}
/**
* What a press DOES. A host's own tile dials with no one-off reference so the host's binding is
* followed; a pin tile forces its own profile. Passing the pin's id as the binding (or the
* other way round) is invisible until someone streams at the wrong bitrate.
*/
@Test
fun activationCarriesTheRightProfileReference() {
val living = host("living", "192.168.1.42", pins = listOf(work.id))
val dialled = mutableListOf<Pair<String, String?>>()
val discovered = mutableListOf<String>()
var addOpened = false
val result = tiles(
savedHosts = listOf(living),
profiles = listOf(work),
pins = mapOf(living.id to listOf(work)),
discoveredUnsaved = listOf(advert("stranger", "192.168.1.99")),
onConnect = { kh, oneOff -> dialled += kh.name to oneOff },
onConnectDiscovered = { dh -> discovered += dh.host },
onAddHost = { addOpened = true },
)
result.forEach { it.activate() }
assertEquals(listOf("living" to null, "living" to work.id), dialled)
assertEquals(listOf("192.168.1.99"), discovered)
assertTrue(addOpened)
}
}
@@ -50,6 +50,7 @@ class ProfilesTest {
hdrEnabled = false,
compositor = 4,
audioChannels = 6,
audioFormat = AUDIO_FORMAT_LOSSLESS_96,
micEnabled = true,
touchMode = TouchMode.POINTER,
mouseMode = MouseMode.CAPTURE,
@@ -67,6 +68,7 @@ class ProfilesTest {
assertFalse(out.hdrEnabled)
assertEquals(4, out.compositor)
assertEquals(6, out.audioChannels)
assertEquals(AUDIO_FORMAT_LOSSLESS_96, out.audioFormat)
assertTrue(out.micEnabled)
assertEquals(TouchMode.POINTER, out.touchMode)
assertEquals(MouseMode.CAPTURE, out.mouseMode)
@@ -236,6 +238,125 @@ class ProfilesTest {
assertEquals(base, made.first().overrides.apply(base))
}
/**
* The audio-format setting is a STRING, and the two numbers it turns into are what the `Hello`
* carries get the mapping wrong and the session either spends 8.5 Mbps it was not asked for
* or silently declines to ask for what it was. The Opus row is the load-bearing one: it must
* be the `0`/`0` "did not ask" sentinel, because core sets `CLIENT_CAP_AUDIO_HIRES` on ANY
* non-zero field see [theOpusSettingDoesNotAdvertiseTheLosslessCapability].
*/
@Test
fun theAudioFormatSettingMapsToTheWireFieldsItClaims() {
assertEquals(
AUDIO_FORMAT_WIRE_UNSPECIFIED,
base.copy(audioFormat = AUDIO_FORMAT_OPUS).audioFormatWire(),
)
// Both rate families. The 44.1 one was deferred only for as long as the shared jitter
// policy divided by 1 000 before it multiplied (44 100 → 44 samples/ms, every buffer
// figure 2.3 % out); core multiplies first now, so these are simply rates.
assertEquals(
44_100 to 24,
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_441).audioFormatWire(),
)
assertEquals(
48_000 to 24,
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_48).audioFormatWire(),
)
assertEquals(
88_200 to 24,
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_882).audioFormatWire(),
)
assertEquals(
96_000 to 24,
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_96).audioFormatWire(),
)
assertEquals(
176_400 to 24,
base.copy(audioFormat = AUDIO_FORMAT_LOSSLESS_1764).audioFormatWire(),
)
// The default is the legacy request — a fresh install asks for exactly what it always did.
assertEquals(AUDIO_FORMAT_WIRE_UNSPECIFIED, Settings().audioFormatWire())
// A newer build's value (or a corrupted pref) falls back to Opus rather than reaching the
// host as an unrepresentable rate: a settings string must never be able to block a connect.
assertEquals(
AUDIO_FORMAT_WIRE_UNSPECIFIED,
base.copy(audioFormat = "lossless192").audioFormatWire(),
)
}
/**
* **A user who chose Standard (Opus) must not advertise `CLIENT_CAP_AUDIO_HIRES`**, and the
* only thing standing between them and 1.5 Mbps of PCM they did not ask for is that this pair
* is `0`/`0`.
*
* Core's `advertised_client_caps` sets the bit when EITHER field is non-zero it keys on "a
* format was specified", not on "the format differs from the default", because 48 kHz/16-bit is
* both the legacy pair AND the cheapest lossless rung and the other rule would make that rung
* unrequestable. The host's gate then accepts 48 kHz/16-bit as a perfectly supported format. So
* a client that sends the legacy-looking numbers as its stand-in for "default" opts every one of
* its users in, on every host that has not deliberately opted out which since 2026-08-17 is
* every host, `PUNKTFUNK_AUDIO_HIRES` having gone default-ON with no surface anywhere saying
* so: a declined session and a silently granted one look identical from the settings screen.
*
* This client did exactly that until the four clients were compared. The rule is restated here
* rather than reached through core because Kotlin cannot call it; core's own tests pin the other
* half.
*/
@Test
fun theOpusSettingDoesNotAdvertiseTheLosslessCapability() {
// Core's rule, verbatim: `audio_rate_hz != 0 || audio_bits != 0`.
fun asksForHiRes(wire: Pair<Int, Int>) = wire.first != 0 || wire.second != 0
assertFalse(asksForHiRes(base.copy(audioFormat = AUDIO_FORMAT_OPUS).audioFormatWire()))
assertFalse(asksForHiRes(Settings().audioFormatWire()))
assertFalse(asksForHiRes(base.copy(audioFormat = "lossless192").audioFormatWire()))
// …and every row that IS a lossless choice must ask, or the setting does nothing at all.
// That asymmetry is the whole contract.
for ((value, _) in AUDIO_FORMAT_OPTIONS.drop(1)) {
assertTrue(value, asksForHiRes(base.copy(audioFormat = value).audioFormatWire()))
}
}
/**
* The stored values are a CROSS-CLIENT contract, shared verbatim with the Apple client's
* `AudioFormatChoice` raw values and the desktop `AUDIO_FORMATS`. A profile carries the key
* through untouched, so a rename here does not break a round trip loudly it breaks it
* silently, by leaving the other client to fall back to its own global default on a profile
* that looks like it applied. Spelled out as literals rather than referenced through the
* constants, because a test that reads the constant cannot detect the constant changing.
*
* The naming rule for anything added later is the kHz figure with the decimal point dropped.
*/
@Test
fun theStoredAudioFormatValuesAreTheOnesEveryOtherClientStores() {
assertEquals("opus", AUDIO_FORMAT_OPUS)
assertEquals("lossless441", AUDIO_FORMAT_LOSSLESS_441)
assertEquals("lossless48", AUDIO_FORMAT_LOSSLESS_48)
assertEquals("lossless882", AUDIO_FORMAT_LOSSLESS_882)
assertEquals("lossless96", AUDIO_FORMAT_LOSSLESS_96)
assertEquals("lossless1764", AUDIO_FORMAT_LOSSLESS_1764)
// Opus first (the default), then the lossless rows by ascending rate.
assertEquals(
listOf(
AUDIO_FORMAT_OPUS,
AUDIO_FORMAT_LOSSLESS_441,
AUDIO_FORMAT_LOSSLESS_48,
AUDIO_FORMAT_LOSSLESS_882,
AUDIO_FORMAT_LOSSLESS_96,
AUDIO_FORMAT_LOSSLESS_1764,
),
AUDIO_FORMAT_OPTIONS.map { it.first },
)
// Every offered row resolves to a DISTINCT request — a duplicate would be a menu entry the
// wire cannot tell from its neighbour — every lossless one is 24-bit, and exactly one row
// (Opus, the first) is the "did not ask" sentinel.
val wire = AUDIO_FORMAT_OPTIONS.map { base.copy(audioFormat = it.first).audioFormatWire() }
assertEquals(wire.size, wire.toSet().size)
assertTrue(wire.drop(1).all { it.second == 24 })
assertEquals(1, wire.count { it == AUDIO_FORMAT_WIRE_UNSPECIFIED })
assertEquals(AUDIO_FORMAT_WIRE_UNSPECIFIED, wire.first())
}
@Test
fun mintedIdsAreWellFormed() {
val id = newProfileId()
@@ -30,10 +30,19 @@ class StatsOverlayAudioTest {
val compose = createAndroidComposeRule<ComponentActivity>()
/**
* A plausible 35-double window with the audio gauges dialled in. Everything before 33 is the
* DETAILED-renderable shape the ShotScenes fixture uses; only the last two matter here.
* A plausible 38-double window with the audio gauges dialled in. Everything before 33 is the
* DETAILED-renderable shape the ShotScenes fixture uses; only the tail matters here. The
* format triple (3537) defaults to an ordinary Opus session, so a test that says nothing
* about it is asserting against the shape every session has always had.
*/
private fun stats(bufferMs: Double, avOffsetMs: Double, size: Int = 35): DoubleArray {
private fun stats(
bufferMs: Double,
avOffsetMs: Double,
size: Int = 38,
codec: Double = 0.0,
rateHz: Double = 48_000.0,
bits: Double = 16.0,
): DoubleArray {
val full = doubleArrayOf(
238.0, 921.4, 1.3, 2.1, 1.0, 1.0, 5120.0, 1440.0, 240.0, 2.0,
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
@@ -42,6 +51,7 @@ class StatsOverlayAudioTest {
0.2, 0.3, 236.0, 1.0,
0.1, 0.3, 0.0,
bufferMs, avOffsetMs,
codec, rateHz, bits,
)
return full.copyOf(size)
}
@@ -91,4 +101,69 @@ class StatsOverlayAudioTest {
show(stats(bufferMs = 42.0, avOffsetMs = 18.0, size = 33))
compose.onNodeWithText("audio buffer", substring = true).assertDoesNotExist()
}
/**
* The RESOLVED audio format (3537) the surface `design/hi-res-audio.md` §10 requires, and
* the only one that can answer "did lossless actually happen". The settings screen shows what
* this device REQUESTED; the host's gate (its own switch off by default) can decline every
* one of them and the session then looks, sounds and measures exactly like a granted one.
* Codec `2` is the `0xD3` lossless plane.
*/
@Test
fun aLosslessSessionNamesTheFormatItResolved() {
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 96_000.0, bits = 24.0))
compose.onNodeWithText("audio lossless 96 kHz / 24-bit").assertExists()
}
/**
* The 44.1 kHz family renders as kHz with a tenth, not as raw Hz.
*
* The rest of the HUD can print whatever reads well; this line cannot, because its entire job
* is to be compared at a glance with the settings row that asked for the format. While the
* ladder was 48/96 only every rate divided by a thousand and the fallback arm was unreachable;
* admitting 44 100 / 88 200 / 176 400 made it the arm half the menu now takes, and "44100 Hz"
* next to a menu saying "44.1 kHz" is one more thing for a reader to have to work out.
*/
@Test
fun aFractionalRateRendersInKilohertzRatherThanRawHertz() {
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 44_100.0, bits = 24.0))
compose.onNodeWithText("audio lossless 44.1 kHz / 24-bit").assertExists()
}
/** …and the top of the ladder, which is the row most likely to have been declined outright. */
@Test
fun theTopOfTheLadderNamesItselfExactly() {
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 176_400.0, bits = 24.0))
compose.onNodeWithText("audio lossless 176.4 kHz / 24-bit").assertExists()
}
/**
* Shown from NORMAL, unlike every other audio figure: a user who paid 4.6 Mbps for this should
* not have to find the DETAILED tier to learn whether they got it.
*/
@Test
fun theFormatLineIsNotReservedForTheDetailedTier() {
show(
stats(bufferMs = 42.0, avOffsetMs = 0.0, codec = 2.0, rateHz = 48_000.0, bits = 24.0),
verbosity = StatsVerbosity.NORMAL,
)
compose.onNodeWithText("audio lossless 48 kHz / 24-bit").assertExists()
}
/**
* Silent for Opus which is every session anyone who never touched the setting will ever run,
* so a line stating it would be noise on almost every HUD. Absence IS the ordinary case.
*/
@Test
fun anOpusSessionSaysNothingAboutTheFormat() {
show(stats(bufferMs = 42.0, avOffsetMs = 0.0))
compose.onNodeWithText("audio lossless", substring = true).assertDoesNotExist()
}
/** An older native lib emits 35 doubles; the format line must be omitted, never mis-indexed. */
@Test
fun aPreFormatNativeLayoutOmitsTheFormatLine() {
show(stats(bufferMs = 42.0, avOffsetMs = 0.0, size = 35, codec = 2.0))
compose.onNodeWithText("audio lossless", substring = true).assertDoesNotExist()
}
}
@@ -127,57 +127,9 @@ class ScreenshotTest {
WakeTimedOutScene()
}
// The console flow is the full-screen aurora takeover (a root capture).
@Test
fun connectingConsole() = shootRoot("connecting-console", statusBar = false) { ConnectConsoleScene() }
@Test
fun consoleSettings() = shootRoot("console-settings", statusBar = false) { ConsoleSettingsScene() }
/** A PALE palette: the whole UI flips to dark ink on white frost, which only a shot proves. */
@Test
fun consoleSettingsLight() =
shootRoot("console-settings-light", statusBar = false) { ConsoleSettingsScene(paletteId = "holo") }
/**
* Landscape the orientation the console actually runs in, and a DIFFERENT layout since the
* on-glass review: rows capped and left-aligned, the focused row's description in a side pane
* on the right instead of the floating band.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleSettingsLandscape() =
shootRoot("console-settings-landscape", statusBar = false) { ConsoleSettingsScene() }
// The console home, the screen the living backdrop is most of. The default sdk (36) draws the
// real AGSL MESH field; the paired API-31 shot below draws the blob fallback, so the two
// renderings of the same palette can be compared rather than assumed equivalent.
@Test
fun consoleHome() = shootRoot("console-home", statusBar = false) { ConsoleHomeScene() }
@Test
fun consoleHomeLight() = shootRoot("console-home-light", statusBar = false) { ConsoleHomeScene(paletteId = "holo") }
/**
* Landscape the orientation the console UI actually runs in, and the only one wide enough to
* show the carousel's NEIGHBOURS, which is where the projected turn (`CARD_TURN_RAD`) lives.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleHomeLandscape() = shootRoot("console-home-landscape", statusBar = false) { ConsoleHomeScene() }
/**
* The API 31/32 field. `RuntimeShader` is API 33+, so everything below it keeps the four
* drifting blobs an honest approximation rather than an emulation, and the thing this shot
* exists to keep honest.
*/
@Test
@Config(sdk = [31], qualifiers = "w360dp-h800dp-xxhdpi")
fun consoleHomeBlobFallback() = shootRoot("console-home-blobs", statusBar = false) { ConsoleHomeScene() }
// The two screens the console reached for the first time in WP8.3. Each is shot on a dark AND a
// pale palette, because the console draws them through a ColorScheme derived from the palette's
// ink — and the pale one is the only place a grey-on-pastel slip can show up.
// The licences view — the one screen the console still opens as a Compose takeover. Shot on a
// dark AND a pale palette, because the console draws it through a ColorScheme derived from the
// palette's ink — and the pale one is the only place a grey-on-pastel slip can show up.
@Test
fun consoleLicenses() = shootRoot("console-licenses", statusBar = false) { ConsoleLicensesScene() }
@@ -185,9 +137,6 @@ class ScreenshotTest {
fun consoleLicensesLight() =
shootRoot("console-licenses-light", statusBar = false) { ConsoleLicensesScene(paletteId = "holo") }
@Test
fun consoleControllers() = shootRoot("console-controllers", statusBar = false) { ConsoleControllersScene() }
/**
* The touch presentation, pads connected landscape, like every store frame: the app is
* built for horizontal use, and a portrait capture shows a layout nobody streams in.
@@ -196,23 +145,13 @@ class ScreenshotTest {
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun controllers() = shootRoot("controllers") { ControllersScene() }
/** The console presentation at the same landscape geometry — the store's FEEL THE GAME frame. */
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun consoleControllersLandscape() =
shootRoot("console-controllers-landscape", statusBar = false) { ConsoleControllersScene() }
/**
* The library coverflow with a mock shelf the store's PICK & PLAY frame. Landscape: the
* orientation the coverflow actually runs in, and the only one wide enough for neighbours.
* The same shelf as the TOUCH grid the presentation a finger gets from a host card's
* "Browse library…". Portrait (the default qualifiers), because that is the orientation a
* phone browses a poster wall in, and the one whose column count the layout has to get right.
*/
@Test
@Config(sdk = [36], qualifiers = "w800dp-h360dp-xxhdpi")
fun library() = shootRoot("library", statusBar = false) { LibraryScene() }
@Test
fun consoleControllersLight() =
shootRoot("console-controllers-light", statusBar = false) { ConsoleControllersScene(paletteId = "holo") }
fun libraryTouch() = shootRoot("library-touch") { TouchLibraryScene() }
@Test
fun trust() = shootScreen("trust") {
@@ -26,10 +26,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BatteryFull
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SignalCellular4Bar
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -51,9 +54,7 @@ import androidx.compose.ui.unit.dp
import io.unom.punktfunk.BrandDark
import io.unom.punktfunk.ConnectModal
import io.unom.punktfunk.ConnectPhase
import io.unom.punktfunk.ConnectTakeover
import androidx.compose.runtime.CompositionLocalProvider
import io.unom.punktfunk.GamepadHome
import io.unom.punktfunk.GamepadInk
import io.unom.punktfunk.GamepadPalette
import coil.ImageLoader
@@ -61,13 +62,11 @@ import coil.test.FakeImageLoaderEngine
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.AddHostSheet
import io.unom.punktfunk.ConsoleControllersScreen
import io.unom.punktfunk.ConsoleHeader
import io.unom.punktfunk.ConsoleLegendInset
import io.unom.punktfunk.ConsoleLicensesScreen
import io.unom.punktfunk.ControllersScreen
import io.unom.punktfunk.Coverflow
import io.unom.punktfunk.GamepadAuroraBackground
import io.unom.punktfunk.TouchGrid
import io.unom.punktfunk.GamepadHintBar
import io.unom.punktfunk.PadGlyph
import io.unom.punktfunk.PadInfo
@@ -77,8 +76,6 @@ import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.library.Artwork
import io.unom.punktfunk.kit.library.GameEntry
import androidx.compose.ui.platform.LocalConfiguration
import io.unom.punktfunk.GamepadSettingsScreen
import io.unom.punktfunk.HomeTile
import io.unom.punktfunk.LocalGamepadInk
import io.unom.punktfunk.LocalGamepadPalette
import io.unom.punktfunk.Settings
@@ -333,7 +330,6 @@ internal fun SettingsProfileScene() {
@Composable
internal fun SpeedTestScene() {
SpeedTestPrompt(
gamepadUi = false,
hostName = "Living Room PC",
target = SpeedTestTarget.Ask(newProfile("Game")),
phase = SpeedTestPhase.Done(throughputKbps = 412_000, lossPct = 0.3, recommendedKbps = 288_400),
@@ -446,12 +442,12 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
Brush.linearGradient(listOf(Color(0xFF2A1E5C), Color(0xFF0E1B3D), Color(0xFF06122B))),
),
) {
// The full 35-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
// The full 38-double unified layout — NativeBridge.nativeVideoStats' KDoc is the
// authoritative index list: [fps, mbps, e2eP50, e2eP95, latValid, skew, w, h, hz,
// lostTotal, bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50,
// decodeP50, hostP50, netP50, lost, skipped, fec, frames, dispValid, displayP50,
// e2eDispP50, e2eDispP95, paceP50, latchP50, presents, presenterActive, feedP50, codecP50,
// skippedOverflow, audioBufferMs, audioAvOffsetMs].
// skippedOverflow, audioBufferMs, audioAvOffsetMs, audioCodec, audioRateHz, audioBits].
// 10/9/16/1 = a 10-bit BT.2020 PQ (HDR) 4:2:0 feed so the DETAILED HUD renders its
// video-feed line; the display stage is valid (dispValid 1) so the headline is the
// directly-measured capture→displayed pair, less the excluded OS present floor (the 0.3
@@ -475,6 +471,11 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
// The audio plane: a 28 ms ring placed 4 ms behind the picture — a converged sync
// loop, i.e. inside the deadband it deliberately leaves alone.
28.0, 4.0,
// The resolved audio format: codec 0 = Opus at 48 kHz/16-bit, which is what an
// ordinary session runs and what these shots are of. The HUD's format line only
// renders for the lossless plane (codec 2), so this triple deliberately adds
// nothing to the capture — the scene shows the shape almost every user sees.
0.0, 48_000.0, 16.0,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
@@ -504,14 +505,6 @@ internal fun WakingScene() =
internal fun WakeTimedOutScene() =
ConnectModal(ConnectPhase.WakeTimedOut("Living Room PC"), onCancel = {}, onRetry = {})
/**
* The console / gamepad connect flow (the real full-screen [ConnectTakeover]) the aurora backdrop
* with a bottom hint bar, the same signature look the console home uses.
*/
@Composable
internal fun ConnectConsoleScene() =
ConnectTakeover(ConnectPhase.Connecting("Living Room PC"), onCancel = {}, onRetry = {})
/**
* The real console settings screen the section tab strip, the glass rows, the focused row's
* unfolded detail, and the living (calmed) backdrop behind them. The touch [SettingsScene] can't
@@ -549,74 +542,40 @@ internal fun StreamBannerScene(pad: Boolean) {
}
/**
* The console HOME the host carousel over the living backdrop, which is the screen the aurora is
* most of. Worth its own shot for exactly that reason: on API 33+ the field is the real bicubic
* MESH (`GamepadAurora`'s AGSL port of the desktop console's shader) and below it the four-blob
* fallback, and the two are only comparable side by side. The scene composes [GamepadHome]
* directly with mock tiles it needs no JNI core and no session, unlike the ConnectScreen that
* normally feeds it.
* Publish the palette locals `App` would normally provide. A scene that calls a console screen
* directly gets the DEFAULT dark ink without this, and a pale-palette shot would then silently
* prove nothing at all.
*/
@Composable
internal fun ConsoleHomeScene(paletteId: String = "violet") {
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
val palette = GamepadPalette.named(paletteId)
val tiles = listOf(
HomeTile(
id = "living", title = "Living Room PC", subtitle = "192.168.1.42 · Paired",
filled = true, online = true, paired = true, activate = {},
),
HomeTile(
id = "studio", title = "studio-deck", subtitle = "192.168.1.61 · Discovered",
online = true, activate = {},
),
HomeTile(id = "add", title = "Add Host", subtitle = "By address", isAdd = true, activate = {}),
)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
GamepadHome(
tiles = tiles,
libraryEnabled = true,
controllerName = "Xbox Wireless Controller",
navActive = false,
onActivate = {},
onOpenLibrary = {},
onOpenSettings = {},
)
content()
}
}
/**
* The two screens the console could not reach at all until WP8.3 the open-source notices and the
* connected-controllers view in their console presentation.
* The one Compose screen the console still opens over itself the open-source notices in its
* console presentation. (Connected controllers used to be its sibling here; it is the console's
* own Skia screen now, covered by pf-console-ui's tests.)
*
* Worth a shot each, and worth a PALE one: both are ordinary Material screens underneath, and the
* console shows them through a `ColorScheme` derived from the palette's ink. That derivation is the
* whole risk. Their touch presentation is inked by the app theme, which is always dark, so nothing
* Worth a shot, and worth a PALE one: it is an ordinary Material screen underneath, and the
* console shows it through a `ColorScheme` derived from the palette's ink. That derivation is the
* whole risk. Its touch presentation is inked by the app theme, which is always dark, so nothing
* before this could catch light-grey body text stranded on a pastel field.
*
* Robolectric enumerates no input devices, so the controllers scenes inject [shotPads] the
* deterministic connected-pads state the store listing needs.
*/
@Composable
internal fun ConsoleLicensesScene(paletteId: String = "violet") =
ConsolePalette(paletteId) { ConsoleLicensesScreen(onBack = {}, navActive = false) }
@Composable
internal fun ConsoleControllersScene(paletteId: String = "violet") =
ConsolePalette(paletteId) {
// Robolectric enumerates no input devices, so the shot injects the two pads the store
// listing talks about — the empty "no controller detected" state proves the palette but
// sells nothing.
ConsoleControllersScreen(
gamepadSetting = 0, onBack = {}, navActive = false, padsOverride = shotPads(),
)
}
/**
* The touch presentation of the same screen, with the same injected pads. Wrapped in a background
* [Surface]: the activity provides the dark ground in the app, and without one here the content
* color falls back to black-on-white while the cards stay dark.
* The controllers screen with [shotPads] injected Robolectric enumerates no input devices, and
* the connected-pad card is the point of the shot. Wrapped in a background [Surface]: the
* activity provides the dark ground in the app, and without one here the content color falls
* back to black-on-white while the cards stay dark.
*/
@Composable
internal fun ControllersScene() =
@@ -641,6 +600,14 @@ internal fun AddHostScene() {
)
}
// The Compose console's scenes (home carousel, settings, coverflow, connect takeover) are gone
// with the screens themselves: the console is the Skia shell now
// (design/android-skia-console-port.md), which renders over native GL and cannot compose under
// Roborazzi. Its store shots come from the desktop screenshot dump (the same pixels) or from a
// device capture. The scenes that remain are the touch UI and the two Compose platform screens
// the console still opens (Controllers, Licences).
/** The two pads the store listing names: DualSense (adaptive triggers, LEDs, rumble) and Xbox. */
internal fun shotPads() = listOf(
PadInfo(
@@ -663,39 +630,34 @@ internal fun shotPads() = listOf(
* prove nothing at all.
*/
/**
* The game-library coverflow (the real [Coverflow] over the real console chrome) with a mock shelf.
* The library screen itself can't be shot its state comes off the network so the scene rebuilds
* the same shell [io.unom.punktfunk.LibraryScreen] draws around it: aurora, header, floating hint
* bar. Cover art is answered synchronously by coil-test's [FakeImageLoaderEngine] with generated
* posters, so the frozen animation clock never races an async load.
* The TOUCH library the poster grid a finger reaches through a host card's "Browse library…",
* with the same mock shelf the coverflow scene uses. Same construction as [LibraryScene]: the real
* [TouchGrid] under a rebuilt header, because the screen around it takes its state off the network.
*/
@Composable
internal fun LibraryScene(paletteId: String = "violet") = ConsolePalette(paletteId) {
internal fun TouchLibraryScene() {
val context = LocalContext.current
val loader = remember { shotLibraryLoader(context) }
val games = remember { shotGames() }
val hazeState = remember { HazeState() }
val landscape =
LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("Living Room PC — Library")
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Coverflow(games, loader, navActive = false, onLaunch = {})
Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 8.dp),
) {
IconButton(onClick = {}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
"Living Room PC — Library",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
)
IconButton(onClick = {}) {
Icon(Icons.Filled.Refresh, contentDescription = "Reload")
}
}
}
Box(
Modifier.align(Alignment.BottomStart)
.consoleLegendInsets(landscape)
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
listOf(PadGlyph.hint('A', "Launch"), PadGlyph.hint('B', "Close")),
hazeState = hazeState,
)
TouchGrid(games, loader, onLaunch = {}, onCopyLink = {}, modifier = Modifier.weight(1f))
}
}
}
@@ -956,29 +918,3 @@ private fun drawEmber(canvas: Canvas) {
glowDot(canvas, x, y, r, shotAlpha(0xFFB067, rng.range(0.35f, 0.9f)))
}
}
@Composable
private fun ConsolePalette(paletteId: String, content: @Composable () -> Unit) {
val palette = GamepadPalette.named(paletteId)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
content = content,
)
}
@Composable
internal fun ConsoleSettingsScene(paletteId: String = "violet") {
// The scene calls the screen directly, so it has to publish the palette locals `App` would
// normally provide — without them a light palette would render with the default DARK ink and
// the shot would silently prove nothing.
val palette = GamepadPalette.named(paletteId)
CompositionLocalProvider(
LocalGamepadPalette provides palette,
LocalGamepadInk provides GamepadInk.of(palette),
) {
GamepadSettingsScreen(
initial = SHOT_SETTINGS.copy(uiPalette = paletteId), onChange = {}, onBack = {},
)
}
}
@@ -40,20 +40,4 @@ class TvScreenshotTest {
@Test
fun streamDetailed() =
shootRoot("stream-detailed") { StreamScene(io.unom.punktfunk.StatsVerbosity.DETAILED) }
@Test
fun consoleHome() = shootRoot("console-home") { ConsoleHomeScene() }
@Test
fun consoleSettings() = shootRoot("console-settings") { ConsoleSettingsScene() }
@Test
fun consoleControllers() = shootRoot("console-controllers") { ConsoleControllersScene() }
/** The library coverflow at TV geometry — the store's PICK & PLAY frame for the TV listing. */
@Test
fun library() = shootRoot("library") { LibraryScene() }
@Test
fun connectingConsole() = shootRoot("connecting-console") { ConnectConsoleScene() }
}
+26
View File
@@ -90,6 +90,32 @@ fun Exec.cargoNdkEnvironment() {
// (pure C) so the android .so links it instead of looking for the host's libopus.so.
environment("LIBOPUS_STATIC", "1")
environment("LIBOPUS_NO_PKG", "1")
// The Skia console (pf-console-ui over skia-bindings): prebuilt Skia archives are keyed by
// target + features. rust-skia's GitHub releases carry no armv7-linux-androideabi archive, so
// ALL three Android keys are served from our own release — public, R2-backed, unauthenticated:
// https://git.unom.io/unom/skia-binaries/releases/download/{tag}/skia-binaries-{key}.tar.gz
// (the armv7 archive built by us, the two 64-bit ones byte-for-byte mirrors of rust-skia's —
// design/android-skia-console-port.md WP6; re-derive + re-upload on every skia-safe bump).
// Override with `-PskiaBinariesUrl=<template>` or the `SKIA_BINARIES_URL` env (`{tag}`/`{key}`
// placeholders, `file://` allowed) — e.g. a local mirror while cutting the next bump's archives.
// 🛑 skia-bindings never fails when no archive matches — it silently builds Skia from source
// for hours; every log must show `DOWNLOAD AND INSTALL SUCCEEDED` per target.
//
// The archives at rust-skia tag 0.99.0 (skia hash a25a0fdb7d90429aa2d1), key
// `<target>-gl-jpegd-jpege-pdf-textlayout`, sha256 — re-derive on every skia-safe bump:
// aarch64-linux-android fdbb25dd2e4ff22ce663b38d368ea696c88a522c73f54010662046b26bcf362c (GitHub)
// x86_64-linux-android 93c1eaf379f539565343e99fdac4414fa22e45daa04de689bb0db2ef9290523b (GitHub)
// armv7-linux-androideabi 4867856bcd1f01c197f796346ba555cffddb5e151f6cd072663ec1a56983d685 (ours:
// FORCE_SKIA_BUILD=1 cargo ndk -t armeabi-v7a build -p pf-console-ui --no-default-features,
// then OUT_DIR/skia/{libskia,libskshaper,libskparagraph,libskunicode_core,libskunicode_icu,
// libskia-bindings}.a + bindings.rs + tag.txt + key.txt packed as skia-binaries/ in
// skia-binaries-<key>.tar.gz)
environment(
"SKIA_BINARIES_URL",
(project.findProperty("skiaBinariesUrl") as String? ?: System.getenv("SKIA_BINARIES_URL"))
?.takeIf { it.isNotBlank() }
?: "https://git.unom.io/unom/skia-binaries/releases/download/{tag}/skia-binaries-{key}.tar.gz",
)
}
fun registerCargoNdk(taskName: String, release: Boolean) =
@@ -3,6 +3,7 @@ package io.unom.punktfunk.kit
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.roundToInt
/**
@@ -127,8 +128,12 @@ object Gamepad {
// Microsoft Xbox One / Series product ids (wired + the common Bluetooth/dongle revisions). All
// behave like Xbox 360 on the host minus the glyph identity, so they share one pref byte.
// The Bluetooth revisions (0x02E0/0x02FD Xbox One S, 0x0B05/0x0B22 Elite Series 2 and its
// Core) are here for the same reason as the wired ones: they are the pads a couch actually
// pairs to a TV box, and without them an Elite streams under the Xbox 360 identity.
private val PID_XBOXONE = setOf(
0x02D1, 0x02DD, 0x02E3, 0x02EA, 0x0B00, 0x0B12, 0x0B13, 0x0B20,
0x02D1, 0x02DD, 0x02E0, 0x02E3, 0x02EA, 0x02FD,
0x0B00, 0x0B05, 0x0B12, 0x0B13, 0x0B20, 0x0B22,
)
/**
@@ -188,9 +193,53 @@ object Gamepad {
s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
}
/** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */
fun pads(): List<InputDevice> =
InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) }
/**
* True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a
* REAL device carrying real pad hardware a stick, a HAT, or the A/B face buttons.
*
* [isPad] alone answers "did this event come from a pad source", which is the right question
* for ROUTING an event and the wrong one for "is a controller attached". Devices publish
* inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing OEM
* game-mode overlays and the gaming-phone shoulder triggers among them and one of those is
* enough to pin the console UI on forever: a pad that was never there cannot disconnect, so
* "With a controller" has no way back to the touch UI.
*
* The capability probe is what separates them: a source class is a claim, a stick or a face
* button is hardware. It is not a complete defence an OEM device that declares `BTN_GAMEPAD`
* and a pair of axes is indistinguishable from a pad at this layer so the master switch stays
* the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic
* device), which is worth excluding but catches none of the above.
*/
fun looksLikeController(dev: InputDevice?): Boolean {
val d = dev ?: return false
return looksLikeController(
padSource = isPad(d),
virtual = d.isVirtual,
hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null ||
d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null,
// `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick,
// a d-pad-only pad) still counts.
hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B)
.any { it },
)
}
/** [looksLikeController]'s decision, over plain facts the seam its truth table is tested at
* (an [InputDevice] cannot be built off a device). */
fun looksLikeController(
padSource: Boolean,
virtual: Boolean,
hasStick: Boolean,
hasFaceButtons: Boolean,
): Boolean = padSource && !virtual && (hasStick || hasFaceButtons)
/**
* All connected controllers, in system enumeration order the devices that answer "is a pad
* attached", so the filter is [looksLikeController] rather than the looser [isPad].
*/
fun pads(): List<InputDevice> = InputDevice.getDeviceIds().toList()
.mapNotNull { InputDevice.getDevice(it) }
.filter { looksLikeController(it) }
/** First connected gamepad/joystick [InputDevice], or null when none is attached. */
fun firstPad(): InputDevice? = pads().firstOrNull()
@@ -293,6 +342,334 @@ object Gamepad {
else -> BTN_BACK
}
// ---------------------------------------------------------------------------------------
// Controllers Android has no key layout for
//
// Android turns a pad's raw evdev scancode into a `KeyEvent.keyCode` through a KEY LAYOUT
// file matched on USB VID/PID (`Vendor_054c_Product_0ce6.kl` & co.). A pad with no matching
// file falls back to AOSP's `Generic.kl`, which assigns keycodes by SCANCODE POSITION —
// `0x130`→BUTTON_A, `0x131`→BUTTON_B, `0x132`→BUTTON_C, and so on up. That is only right if
// the pad's buttons happen to sit at the positions the file assumes, and a HID gamepad with
// no kernel driver behind it numbers its buttons 1..n straight through IN ITS OWN REPORT
// ORDER — so every keycode after the first divergence is somebody else's button.
//
// Reported from a Fire TV Stick 4K Max (2026-08-20): a DualSense and an Xbox Elite Series 2,
// both over Bluetooth, both identified correctly but with buttons landing on the wrong
// actions ("L1 being L2"). Neither has a layout there — AOSP ships none for the Elite
// Series 2 over Bluetooth (`045e:0b05`) on ANY version, and the DualSense's
// (`054c:0ce6`) both postdates Fire OS and carries `requires_kernel_config
// CONFIG_HID_PLAYSTATION`, which a Fire TV kernel does not have. A DualSense reporting
// straight through puts L2 on `0x136`, which `Generic.kl` calls BUTTON_L1: the reported
// symptom exactly.
//
// The fix is to resolve buttons from the SCANCODE, which is the pad's own report position and
// is immune to the layout file — the same reason [Keymap.toVk] reads `scanCode` for keyboards.
// Two things keep it from breaking a pad that already works:
//
// 1. Nothing is corrected on a pad that names its triggers ([padButtons]). A descriptor
// well-formed enough to call them Accelerator/Brake puts its buttons at the standard
// positions too, and that is the fact — not the model — that separates the two firmwares
// of the SAME Xbox pad, only the older of which needs any of this.
// 2. Past that gate the correction still applies ONLY where the delivered keycode is what
// `Generic.kl` would have said ([genericKeyCode]). A different keycode means a
// device-specific layout IS in force and knows this pad better than we do.
//
// Moonlight carries the same two tables AND the same gate (`ControllerHandler`'s
// `isNonStandardDualShock4` / `isNonStandardXboxBtController`, the latter on `gasRange == null`),
// which is why both pads work there on the same box.
//
// The first cut of this asked `hasKeys(BUTTON_C, BUTTON_Z)` on its own, on the reasoning that a
// pad numbering straight through reaches keycodes no controller has a button for. It does — but
// so does every pad that merely DECLARES six buttons, because `hid-input` allocates `BTN_A + n`
// straight through for the whole descriptor whether or not the pad ever presses them. That fired
// the correction on pads Android was already reading correctly (2026-08-21: an Xbox pad
// answering X with Y, Y with LB, and both shoulders with a menu button), and it could not have
// done otherwise: the signal is identical on the firmware that needs correcting and the one that
// does not. Declaration is not report order. Only the axes tell them apart.
/** [MotionEvent] axis id meaning "this pad has no such axis" — see [PadMap]. */
const val AXIS_NONE = -1
/**
* The report order a controller's buttons are numbered in, and with it which scancode carries
* which physical button. Resolved once per device by [padButtons] from what the device
* declares; [correct] then maps one scancode to the keycode it should have produced.
*/
enum class PadButtons {
/**
* The keycode Android delivered is already right a device-specific key layout is in
* force, or the generic one happens to agree. [correct] changes nothing.
*/
NATIVE,
/**
* A Sony pad numbering straight through with no kernel driver behind it: L1 R1
* L2 R2 Create Options L3 R3 PS, i.e. `0x130`..`0x13c` in that order. The analog trigger
* value rides `AXIS_RX`/`AXIS_RY` on such a pad, so the digital L2/R2 fold to keycodes
* [buttonBit] deliberately drops the wire carries the axis, never both.
*/
GENERIC_SONY,
/**
* An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS, i.e.
* `0x130`..`0x139`. Also the fallback for an unbranded pad, which near-universally
* clones the Xbox layout the same assumption [styleFor] makes for its glyphs.
*/
GENERIC_XBOX,
/**
* A Sony pad WITH a kernel driver (`hid-playstation` / `hid-sony`) but still no key
* layout the combination an Android 11 box on a 5.10 kernel lands in. Such a driver
* emits the modern Linux gamepad codes, where `0x133` is BTN_NORTH () and `0x134` is
* BTN_WEST (); `Generic.kl` reads those two as BUTTON_X and BUTTON_Y, so exactly the
* face pair comes out swapped and nothing else is wrong.
*/
SONY_MODERN,
;
/**
* The keycode scancode [scan] should have produced, given Android delivered [keyCode].
*
* Returns [keyCode] untouched unless it is precisely what [genericKeyCode] would have
* said for [scan] anything else is a device-specific layout's answer, which outranks
* this table. That guard is what makes the correction idempotent and safe to run on
* every pad: it can only ever fire where Android was guessing in the first place.
*/
fun correct(scan: Int, keyCode: Int): Int {
if (this == NATIVE) return keyCode
if (keyCode != genericKeyCode(scan)) return keyCode
val fixed = when (this) {
GENERIC_SONY -> when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_X // □
0x131 -> KeyEvent.KEYCODE_BUTTON_A // ✕
0x132 -> KeyEvent.KEYCODE_BUTTON_B // ○
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // △
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_L2 // analog: AXIS_RX
0x137 -> KeyEvent.KEYCODE_BUTTON_R2 // analog: AXIS_RY
0x138 -> KeyEvent.KEYCODE_BUTTON_SELECT // Create / Share
0x139 -> KeyEvent.KEYCODE_BUTTON_START // Options
0x13a -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13b -> KeyEvent.KEYCODE_BUTTON_THUMBR
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE // PS
// 0x13d touchpad click / 0x13e mute: no wire button, dropped as before.
else -> KeyEvent.KEYCODE_UNKNOWN
}
GENERIC_XBOX -> when (scan) {
0x132 -> KeyEvent.KEYCODE_BUTTON_X
0x133 -> KeyEvent.KEYCODE_BUTTON_Y
0x134 -> KeyEvent.KEYCODE_BUTTON_L1
0x135 -> KeyEvent.KEYCODE_BUTTON_R1
0x136 -> KeyEvent.KEYCODE_BUTTON_SELECT // View
0x137 -> KeyEvent.KEYCODE_BUTTON_START // Menu
0x138 -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x139 -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> keyCode // 0x130 A / 0x131 B already agree
}
// Only the face pair; every other row of Generic.kl is right for these codes.
SONY_MODERN -> when (scan) {
0x133 -> KeyEvent.KEYCODE_BUTTON_Y // BTN_NORTH = △
0x134 -> KeyEvent.KEYCODE_BUTTON_X // BTN_WEST = □
else -> keyCode
}
NATIVE -> keyCode
}
return fixed
}
}
/**
* AOSP `Generic.kl`'s gamepad rows the layout Android falls back to when no device-specific
* key layout matches the pad's VID/PID. Scancodes outside it answer [KeyEvent.KEYCODE_UNKNOWN],
* which never equals a real delivered keycode, so [PadButtons.correct]'s guard leaves those
* events alone.
*/
fun genericKeyCode(scan: Int): Int = when (scan) {
0x130 -> KeyEvent.KEYCODE_BUTTON_A
0x131 -> KeyEvent.KEYCODE_BUTTON_B
0x132 -> KeyEvent.KEYCODE_BUTTON_C
0x133 -> KeyEvent.KEYCODE_BUTTON_X
0x134 -> KeyEvent.KEYCODE_BUTTON_Y
0x135 -> KeyEvent.KEYCODE_BUTTON_Z
0x136 -> KeyEvent.KEYCODE_BUTTON_L1
0x137 -> KeyEvent.KEYCODE_BUTTON_R1
0x138 -> KeyEvent.KEYCODE_BUTTON_L2
0x139 -> KeyEvent.KEYCODE_BUTTON_R2
0x13a -> KeyEvent.KEYCODE_BUTTON_SELECT
0x13b -> KeyEvent.KEYCODE_BUTTON_START
0x13c -> KeyEvent.KEYCODE_BUTTON_MODE
0x13d -> KeyEvent.KEYCODE_BUTTON_THUMBL
0x13e -> KeyEvent.KEYCODE_BUTTON_THUMBR
else -> KeyEvent.KEYCODE_UNKNOWN
}
/**
* How one controller must be read: its button report order plus the axes its right stick and
* analog triggers actually arrive on. Resolved once per device by [padMap].
*/
class PadMap(
val buttons: PadButtons,
val rightStickX: Int = MotionEvent.AXIS_Z,
val rightStickY: Int = MotionEvent.AXIS_RZ,
/**
* The trigger axes, or [AXIS_NONE] for a pad Android already names them on that case
* keeps folding LTRIGGER with BRAKE and RTRIGGER with GAS by max, which is what pads that
* report one pair, the other, or both have always needed.
*/
val leftTrigger: Int = AXIS_NONE,
val rightTrigger: Int = AXIS_NONE,
/** Those trigger axes rest at 1 rather than 0, measured off the device's own range. */
val triggersSigned: Boolean = false,
) {
/** One resolved trigger axis value, folded to the 0..1 the wire scale expects. */
fun level(v: Float): Float = if (triggersSigned) (v + 1f) / 2f else v
}
/** The map every pad with a key layout uses: Android's own names, unchanged. */
private val NATIVE_MAP = PadMap(PadButtons.NATIVE)
/**
* Resolved [PadMap]s, keyed by [InputDevice.getDescriptor] the device's stable identity
* hash, so a pad that reconnects is recognised and a model resolves once for the process.
* Nothing here depends on a live connection, so entries never need evicting.
*/
private val padMaps = ConcurrentHashMap<String, PadMap>()
/**
* Which report order [dev]'s buttons follow [namedTriggers] is whether the pad reports its
* triggers under a name Android knows (see [padMap]), and [declaresCZ] whether it declares
* BUTTON_C and BUTTON_Z.
*
* `namedTriggers` decides it, and a pad that has them is [PadButtons.NATIVE] whatever else it
* says. A HID gamepad describes its triggers either as the Accelerator/Brake usages, which
* become `ABS_GAS`/`ABS_BRAKE` and axis names Android has words for, or as two more generic
* axes on `ABS_Z`/`ABS_RZ`, which it does not and a report descriptor well-formed enough to
* name its triggers puts its buttons at the standard positions too, the ones `Generic.kl`
* already reads correctly. It is the same fact Moonlight decides this on (`gasRange == null`
* beside the `"Xbox Wireless Controller"` name), and it is the one that separates the two
* firmwares of the SAME pad: an Xbox Wireless Controller over Bluetooth reports GAS/BRAKE
* after its firmware update and Z/Rz before it, and only the older one needs correcting.
*
* `declaresCZ` cannot make that call and must never be asked to. `hasKeys` answers for what a
* device DECLARES, not what it reports: `hid-input` allocates `BTN_A + n` straight through for
* every button in the descriptor, so BTN_C (`0x132`) and BTN_Z (`0x135`) are set on any pad
* declaring six or more a standard-layout pad that never presses either included. Read alone
* it fired the correction on pads whose buttons were already right, which is how an Xbox pad
* came to answer X with Y and Y with LB (field reports, 2026-08-21). It stays as the narrower
* question it can answer WHICH straight-through order, once `namedTriggers` has established
* there is one where a false positive costs nothing.
*/
fun padButtons(dev: InputDevice, namedTriggers: Boolean): PadButtons {
val has = dev.hasKeys(KeyEvent.KEYCODE_BUTTON_C, KeyEvent.KEYCODE_BUTTON_Z, 0)
return padButtons(namedTriggers, dev.vendorId == VID_SONY, declaresCZ = has[0] && has[1])
}
/** [padButtons]'s choice over plain facts the seam its truth table is tested at (an
* [InputDevice] cannot be built off a device). */
fun padButtons(namedTriggers: Boolean, sony: Boolean, declaresCZ: Boolean): PadButtons = when {
namedTriggers -> PadButtons.NATIVE
declaresCZ && sony -> PadButtons.GENERIC_SONY
declaresCZ -> PadButtons.GENERIC_XBOX
sony -> PadButtons.SONY_MODERN
else -> PadButtons.NATIVE
}
/**
* The [PadMap] for [dev] its button report order and the axes its right stick and triggers
* arrive on, resolved once per device model and cached.
*
* Axes get the same treatment as buttons: a pad Android has a layout for names its triggers
* LTRIGGER/RTRIGGER (or BRAKE/GAS, or BRAKE/THROTTLE) and is left exactly as it was. A pad
* with NONE of those names is one Android never mapped, and its triggers are sitting on two
* raw axes under the names the HID report gave them. Which two depends on the same report
* order the buttons did:
*
* - a Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry = left stick, right
* stick, then the triggers so the right stick is already right and only the triggers
* (`AXIS_RX`/`AXIS_RY`) are missed;
* - every other such pad puts the right stick on Rx/Ry and the triggers on Z/Rz, which is
* the shape that makes pulling a trigger swing the right stick.
*
* Whether those axes idle at 1 is MEASURED from the device's own range rather than assumed,
* so a pad that reports an honest 0..1 is not rescaled to a permanent half-pull.
*/
fun padMap(dev: InputDevice?): PadMap {
if (dev == null) return NATIVE_MAP
padMaps[dev.descriptor]?.let { return it }
fun has(a: Int) = axis(dev, a) != null
val named = (has(MotionEvent.AXIS_LTRIGGER) && has(MotionEvent.AXIS_RTRIGGER)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_GAS)) ||
(has(MotionEvent.AXIS_BRAKE) && has(MotionEvent.AXIS_THROTTLE))
val buttons = padButtons(dev, namedTriggers = named)
val rx = axis(dev, MotionEvent.AXIS_RX)
val hasRxRy = rx != null && has(MotionEvent.AXIS_RY)
// Whichever pair the fallback is about to pick, ask THAT one where it rests.
val restsNegative = if (buttons == PadButtons.GENERIC_SONY) {
(rx?.min ?: 0f) < -0.5f
} else {
(axis(dev, MotionEvent.AXIS_Z)?.min ?: 0f) < -0.5f
}
val map = padMap(buttons, namedTriggers = named, hasRxRy = hasRxRy, restsNegative = restsNegative)
padMaps[dev.descriptor] = map
return map
}
/**
* The axis half of [padMap], decided from four facts about the device so it can be pinned
* without one see `PadButtonsTest`. [namedTriggers] is whether the pad calls its triggers
* anything Android knows (LTRIGGER/RTRIGGER, BRAKE/GAS, BRAKE/THROTTLE); if it does, nothing
* here applies and the pad is read exactly as it always was. [restsNegative] is measured off
* whichever axis pair the fallback picks, never assumed.
*/
fun padMap(
buttons: PadButtons,
namedTriggers: Boolean,
hasRxRy: Boolean,
restsNegative: Boolean,
): PadMap = when {
namedTriggers || !hasRxRy -> PadMap(buttons)
// X, Y, Z, Rz, Rx, Ry = left stick, right stick, triggers. The sticks already read right.
buttons == PadButtons.GENERIC_SONY -> PadMap(
buttons,
leftTrigger = MotionEvent.AXIS_RX,
rightTrigger = MotionEvent.AXIS_RY,
triggersSigned = restsNegative,
)
// Right stick on Rx/Ry and triggers on Z/Rz — the shape in which reading Z/Rz as the
// right stick makes pulling a trigger swing it.
else -> PadMap(
buttons,
rightStickX = MotionEvent.AXIS_RX,
rightStickY = MotionEvent.AXIS_RY,
leftTrigger = MotionEvent.AXIS_Z,
rightTrigger = MotionEvent.AXIS_RZ,
triggersSigned = restsNegative,
)
}
/** [dev]'s range for one joystick [axis], under either source class a pad reports on. */
private fun axis(dev: InputDevice, axis: Int): InputDevice.MotionRange? =
dev.getMotionRange(axis, InputDevice.SOURCE_JOYSTICK)
?: dev.getMotionRange(axis, InputDevice.SOURCE_GAMEPAD)
/**
* The keycode [event] should have carried, given the controller it came from [event]'s own
* keycode for every pad Android has a key layout for, and the scancode's true button for one
* it does not (see the block comment above [PadButtons]).
*
* A drop-in for `event.keyCode` at every gamepad reader: the console UI's navigation, the
* Controllers screen's tester, and the streaming branch all route through it, so a mis-mapped
* pad is fixed in the menus and in the game at once. Events from anything that is not a
* controller, and events with no scancode (soft keyboards, synthetic events), pass through
* untouched.
*/
fun padKeyCode(event: KeyEvent): Int {
val dev = event.device ?: return event.keyCode
if (event.scanCode == 0 || !isPad(dev)) return event.keyCode
return padMap(dev).buttons.correct(event.scanCode, event.keyCode)
}
/**
* Maps one controller's joystick MotionEvents to axis (+ HATdpad) sends on wire pad index [pad],
* **on change only**. Holds the previous axis/hat state so an unchanged frame emits nothing. One
@@ -306,7 +683,12 @@ object Gamepad {
* node (DualSense/DS4 motion sensors), which reports every pad axis as 0. [onMotion] therefore
* folds the event straight in without re-qualifying it.
*/
class AxisMapper(private val handle: Long, private val pad: Int) {
class AxisMapper(
private val handle: Long,
private val pad: Int,
/** Which axes this controller's right stick and triggers arrive on — see [padMap]. */
private val map: PadMap = NATIVE_MAP,
) {
// Sentinel so the first real value (incl. 0) always sends once after attach (Linux parity).
private val last = IntArray(6) { Int.MIN_VALUE }
private var hatX = 0 // -1 / 0 / +1
@@ -317,30 +699,18 @@ object Gamepad {
// Sticks: Android floats 1..1, +y = down → ±32767, negate Y for the wire's +y = up.
sendAxis(AXIS_LS_X, stick(event.getAxisValue(MotionEvent.AXIS_X)))
sendAxis(AXIS_LS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_Y)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(MotionEvent.AXIS_Z)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(MotionEvent.AXIS_RZ)))
sendAxis(AXIS_RS_X, stick(event.getAxisValue(map.rightStickX)))
sendAxis(AXIS_RS_Y, stick(-event.getAxisValue(map.rightStickY)))
// Triggers: pads report LTRIGGER/RTRIGGER or BRAKE/GAS (some mirror both) — merge
// with max, the same fold as the Controllers screen probe, so a pad that reports
// only one pair and a pad that reports both behave identically; 0..1 → 0..255.
sendAxis(
AXIS_LT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_LTRIGGER),
event.getAxisValue(MotionEvent.AXIS_BRAKE),
),
),
)
sendAxis(
AXIS_RT,
trigger(
maxOf(
event.getAxisValue(MotionEvent.AXIS_RTRIGGER),
event.getAxisValue(MotionEvent.AXIS_GAS),
),
),
)
// only one pair and a pad that reports both behave identically; 0..1 → 0..255. A pad
// reporting NONE of those names is one Android has no key layout for, and [map]
// carries the raw axes its triggers really landed on instead.
val lt = resolved(event, map.leftTrigger, MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_BRAKE)
val rt = resolved(event, map.rightTrigger, MotionEvent.AXIS_RTRIGGER, MotionEvent.AXIS_GAS)
sendAxis(AXIS_LT, trigger(lt))
sendAxis(AXIS_RT, trigger(rt))
// HAT → dpad button transitions. Android BATCHES joystick ACTION_MOVEs, so a rapid d-pad
// tap (press+release inside one batch window) lives only in the historical samples — the
@@ -383,6 +753,17 @@ object Gamepad {
hatY = 0
}
/**
* One trigger's 0..1 value: [resolvedAxis] when this pad needed one resolved for it,
* else the max of the two names Android gives a trigger it does know.
*/
private fun resolved(event: MotionEvent, resolvedAxis: Int, named: Int, alias: Int): Float =
if (resolvedAxis == AXIS_NONE) {
maxOf(event.getAxisValue(named), event.getAxisValue(alias))
} else {
map.level(event.getAxisValue(resolvedAxis))
}
private fun sendAxis(id: Int, v: Int) {
if (last[id] == v) return
last[id] = v
@@ -605,7 +605,7 @@ class GamepadRouter(
// for the slot's life; the sensor path reads it on every sample.
val slot = Slot(
index,
Gamepad.AxisMapper(handle, index),
Gamepad.AxisMapper(handle, index, Gamepad.padMap(dev)),
NativeBridge.nativePadMotionReaches(handle, pref),
)
slots[dev.id] = slot
@@ -56,6 +56,28 @@ object NativeBridge {
* decode loop then feeds slices with `BUFFER_FLAG_PARTIAL_FRAME` as they arrive). */
framePartsOk: Boolean,
audioChannels: Int,
/** Requested audio sample rate: **`0` (with [audioBits] `0`) for the legacy Opus plane**, or
* any rung of the lossless ladder `44100`, `48000`, `88200`, `96000`, `176400`, both rate
* families.
*
* **`48000`/`16` is NOT "the default" it is the cheapest lossless rung.** Core sets
* `CLIENT_CAP_AUDIO_HIRES` when either field is non-zero (it keys on "a format was
* specified", so that 48/16 lossless is requestable at all), and the host's gate accepts
* 48 kHz/16-bit as a supported format. Passing it as a stand-in for "unset" opts every
* session into the `0xD3` plane on every host that has not deliberately opted out which
* since 2026-08-17 is all of them, the host gate having gone default-ON. Send `0`/`0`.
*
* A request on BOTH counts. The host runs its gate (its own `PUNKTFUNK_AUDIO_HIRES` switch
* among them now an opt-OUT, so it declines only at `=0` plus whether a frame of this
* format fits one datagram at all) and may answer
* Opus; and the native side first proves THIS device can open the rate AAudio grants an
* explicit rate or fails the open, and there is no recovery once the wire is negotiated
* walking a fallback ladder and downgrading the request if it cannot. */
audioRateHz: Int,
/** Requested audio sample depth: `0` alongside a `0` [audioRateHz] for the legacy Opus
* plane, else `16` or `24`. See [audioRateHz] for why `16` is a request rather than a
* default; 24-bit is where lossless earns its bandwidth. */
audioBits: Int,
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this its GPU. */
videoCodecs: Int,
@@ -124,6 +146,14 @@ object NativeBridge {
name: String,
): String
/**
* The native client's recent log ring rendered as one text bundle, oldest first,
* prefixed by [header] (this app's identity line) the body for "Send logs to host"
* (`POST /api/v1/client-logs` over the same mTLS client the library fetch uses).
* Never empty; cheap (string copy, no I/O).
*/
external fun nativeRenderLogs(header: String): String
/**
* The machine token of the most recent failed [nativeConnect]/[nativePair], cleared on read
* (`""` when none) call right after a `0` handle / `""` fingerprint. A typed host rejection
@@ -261,8 +291,25 @@ object NativeBridge {
/** The display mode's own refresh rate (0 = unknown) the latch grid the presenter
* subdivides onto when the platform down-rates the app's choreographer stream. */
panelFps: Int,
/** The video SurfaceView's on-screen pixel size (0 = not laid out yet). The ASurfaceControl
* present backend composites its layer in this coordinate space the aspect-fitted display
* footprint rather than the window's rotated/scaled buffer geometry. */
surfaceW: Int,
surfaceH: Int,
)
/**
* Re-report the video SurfaceView's on-screen pixel size call it from every `surfaceChanged`.
*
* The ASurfaceControl present backend composites the picture into exactly this rectangle, and
* the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and
* switches the window to draw into the display cutout a frame or two later, and neither
* recreates the surface. Without this the layer keeps painting at its start-up size in the
* corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle;
* cheap (one atomic store), UI-safe.
*/
external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
external fun nativeStopVideo(handle: Long)
@@ -275,12 +322,13 @@ object NativeBridge {
/**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 35 doubles (unified stats spec, `design/stats-unification.md`):
* Returns 38 doubles (unified stats spec, `design/stats-unification.md`):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
* feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
* feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs, audioCodec,
* audioRateHz, audioBits]`
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capturedecoded headline; 1013
* describe the negotiated video feed bit depth 8/10, CICP primaries/transfer, and the HEVC
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline
@@ -299,7 +347,12 @@ object NativeBridge {
* `skipped` (19), i.e. the decoder falling behind rather than benign newest-wins pacing;
* 33/34 are the AUDIO plane the playback ring's live depth in ms and the A/V sync loop's
* smoothed offset in ms, positive meaning audio plays BEHIND the picture. Those two are live
* gauges, not windowed samples, and the offset reads 0 until the loop has a video reference).
* gauges, not windowed samples, and the offset reads 0 until the loop has a video reference;
* 3537 are the audio FORMAT the host RESOLVED at the handshake `audioCodec` 0 = Opus on
* `0xC9`, 2 = lossless PCM on `0xD3` plus the resolved rate in Hz and depth in bits. Static
* for the session, and separate from 33/34 because they answer a different question: not "how
* late is the audio" but "is this the format the user asked for", which nothing else can tell
* apart a declined lossless session looks exactly like a granted one from the outside).
* Poll ~1 Hz; each call resets the measurement window.
*/
external fun nativeVideoStats(handle: Long): DoubleArray?
@@ -612,4 +665,143 @@ object NativeBridge {
* Returns the byte count, or -1 on timeout / session closed.
*/
external fun nativeNextHidout(handle: Long, buf: java.nio.ByteBuffer): Int
// ---- The Skia console UI (crates/pf-console-ui over EGL/GLES — clients/android/native/src/console) ----
//
// The same console shell the Linux/Windows session binary shows, drawn by native onto a
// SurfaceView. Kotlin keeps the services and feeds the console's models as JSON in the model
// types' own serde shape (HostRow, LibraryGame, ConsoleCmd, OverlayAction, Settings — see
// `crates/pf-console-ui/src/model.rs` and `pf-client-core/src/trust.rs`); what the console
// raises comes back through [nativeConsoleNextEvent]. Every call is main-thread-safe and cheap
// except the two polls, which block ~100 ms and belong on their own threads.
/**
* Whether this `.so` carries the console host at all. Present on EVERY ABI armeabi-v7a has no
* prebuilt Skia archive yet, so there the rest of these symbols DO NOT EXIST and calling one is
* an UnsatisfiedLinkError. Ask this first.
*/
external fun nativeConsoleAvailable(): Boolean
/**
* Build the console: [optionsJson] = `{device_name, gpu_cache_bytes, settings: <trust::Settings>,
* profiles: [[id, name]], known_hosts: <KnownHosts>, entry: {} | {"library": <HostRow>}}`.
* Returns a handle (its render thread parked until a surface arrives), or `0` on a bad options
* document. EGL/Skia failures arrive later as a `{"dead": }` event.
*/
external fun nativeConsoleCreate(optionsJson: String): Long
/** Stop the render thread (joined) and free. Stop + join the event poll thread FIRST. */
external fun nativeConsoleDestroy(handle: Long)
/** The SurfaceView's surface is up. */
external fun nativeConsoleSurfaceCreated(handle: Long, surface: android.view.Surface)
/** The surface's size changed. */
external fun nativeConsoleSurfaceChanged(handle: Long)
/** BLOCKS until the render thread has let go of the surface — call from `surfaceDestroyed`. */
external fun nativeConsoleSurfaceDestroyed(handle: Long)
/** Safe-area insets in surface pixels + the design-unit scale (`0` = the shell's own formula). */
external fun nativeConsoleSetViewport(
handle: Long,
left: Float,
top: Float,
right: Float,
bottom: Float,
scale: Float,
)
/**
* The raw pad, whenever it changes: [buttons] bit i = a, b, x, y, l1, r1 held; [lx]/[ly] the left
* stick in wire units (±32767, +y down); [dpad] bit i = up, down, left, right held. Native runs
* the shared menu synthesizer over it (dead zone, repeat, hysteresis).
*/
external fun nativeConsolePadSample(handle: Long, buttons: Int, lx: Int, ly: Int, dpad: Int)
/**
* A discrete menu event: 0..3 move up/down/left/right, 4 confirm, 5 back, 6 secondary (Y),
* 7 tertiary (X), 8 jump back (L1), 9 jump forward (R1). For input that is already an event on
* this side (a TV remote's D-pad keys, the touch legend).
*/
external fun nativeConsoleMenu(handle: Long, event: Int)
/**
* Pointer input in surface pixels: [kind] 0 move, 1 primary down, 2 primary up, 3 secondary
* down (= Back), 4 wheel ([dy] steps, + = up), 5 cancel.
*/
external fun nativeConsolePointer(handle: Long, kind: Int, x: Float, y: Float, dy: Float)
/**
* A hardware key the console understands: 0..3 left/right/up/down, 4 return, 5 space,
* 6 escape, 7 backspace, 8 page up, 9 page down, 10 tab, 11 Y, 12 X.
*/
external fun nativeConsoleKey(handle: Long, key: Int, shift: Boolean, repeat: Boolean)
/** Typed characters while the console reports `{"editing": true}`. */
external fun nativeConsoleText(handle: Long, text: String)
/**
* Where the session the console asked for stands: 0 connecting, 1 streaming, 2 failed([message]),
* 3 ended ([message] = the abnormal reason, or "" for a clean end), 4 reconnecting([message]).
*/
external fun nativeConsoleSessionPhase(handle: Long, phase: Int, message: String)
/** Re-root the console: `{}` = Home, `{"library": <HostRow>}` = that host's shelf over Home. */
external fun nativeConsoleNavigate(handle: Long, entryJson: String)
/**
* The connected controllers: `{"label": "DualSense", "pref": 2, "pads": [{name, key, pref,
* steam_virtual, battery: {percent, charging} | null}]}` the chip and the settings rows.
*/
external fun nativeConsoleSetPads(handle: Long, padsJson: String)
/**
* Block up to ~100 ms for the next event: `{"action": <OverlayAction>}`, `{"pulse": "move" |
* "confirm" | "boundary"}`, `{"editing": bool}`, `{"settings": <Settings>}` (persist it),
* `{"gles": 2 | 3}`, `{"dead": "<why>"}`. `""` on timeout. Call from a dedicated poll thread.
*/
external fun nativeConsoleNextEvent(handle: Long): String
/** Every `ConsoleCmd` queued since the last call, as a JSON array (`[]` when none). */
external fun nativeConsoleDrainCmds(handle: Long): String
/** The home carousel's rows: `[HostRow]`. */
external fun nativeConsoleSetHosts(handle: Long, json: String)
/** The pairing ceremony's phase: `"Idle"`, `"Busy"`, `{"Failed": "why"}`, `{"Paired": {"key": …}}`. */
external fun nativeConsoleSetPair(handle: Long, json: String)
/** The wake card's status (`WakeStatus` JSON) or `null` to clear it. */
external fun nativeConsoleSetWake(handle: Long, json: String)
/** A one-shot toast from a service worker. */
external fun nativeConsoleNotice(handle: Long, text: String)
/** A library fetch is starting for the shelf on screen (bumps the epoch, sets Loading). */
external fun nativeConsoleLibraryBegin(handle: Long)
/** `"Loading"`, `"Empty"`, `"Ready"`, or `{"Error": {"title", "body", "can_retry"}}`. */
external fun nativeConsoleLibraryPhase(handle: Long, json: String)
/** The catalog `[LibraryGame]`; [cached] = the last-known list shown while the fetch runs. */
external fun nativeConsoleLibraryGames(handle: Long, json: String, cached: Boolean)
/** One title's poster, encoded (JPEG/PNG bytes). */
external fun nativeConsoleLibraryArt(handle: Long, id: String, bytes: ByteArray)
/** The ids the host has up: `["steam:570", …]`. */
external fun nativeConsoleLibraryRunning(handle: Long, json: String)
/** 0 fresh, 1 waking, 2 offline — the cached shelf's staleness note. */
external fun nativeConsoleLibraryStale(handle: Long, stale: Int)
/** A settings change made elsewhere (touch UI, deep link): the shell reads it next. Not a save. */
external fun nativeConsoleSetSettings(handle: Long, json: String)
/** The profile catalog `[[id, name]]`. */
external fun nativeConsoleSetProfiles(handle: Long, json: String)
/** The known-hosts records (`KnownHosts` JSON) the console builds `punktfunk://` links from. */
external fun nativeConsoleSetKnownHosts(handle: Long, json: String)
}
@@ -55,6 +55,11 @@ data class GameEntry(
val art: Artwork,
val role: String? = null,
val icon: String? = null,
/**
* The host's platform tag (`platform` in the catalog a ROM manager's console name; Steam
* sets none). What the console's Collections group by; carried through verbatim.
*/
val platform: String? = null,
) {
val isCustom: Boolean get() = store == "custom"
@@ -105,6 +110,49 @@ sealed class LibraryResult {
data class Ok(val games: List<GameEntry>) : LibraryResult()
data class Unauthorized(val message: String) : LibraryResult()
data class Error(val message: String) : LibraryResult()
/**
* Is this the "can't reach it" failure the only one worth waiting out?
*
* A rejected certificate does not become acceptable by retrying, and asking an unpaired host
* twelve times only delays telling the user what is actually wrong. Lives here rather than at
* the call site so the retry loop and the error copy can never disagree about which failures
* are transient.
*/
val isTransient: Boolean get() = this is Error
}
/**
* One game the host currently has launched, from `GET /api/v1/status`.
*
* A deliberately partial mirror of the host's `ActiveGame`: only the fields a client can act on.
* The web console's view of this payload carries more (which session, which plane, the grace
* countdown), and none of that is a player's business from the library shelf.
*/
data class RunningGame(
/**
* Store-qualified library id (`steam:570`) the key that lines this up with a [GameEntry].
* Null for an operator-typed GameStream command, which has no catalog entry behind it.
*/
val appId: String?,
val title: String,
/**
* `launching` | `running` | `exited` | `untracked` | `grace`. A plain String on purpose: the
* host owns the vocabulary and adds to it (`untracked` arrived in 0.30), so an unknown value
* must never fail the decode of the whole list.
*/
val state: String,
) {
/**
* Is this title *up on the host right now* i.e. would picking it take the player back into
* it rather than start it?
*
* `untracked` counts: the host cannot follow that process, but it did launch it and has no
* evidence it stopped. `grace` counts too its session is gone but the game is still running,
* which is precisely the case where getting back in promptly matters most. Only a confirmed
* `exited` does not.
*/
val isUp: Boolean get() = state != "exited"
}
object LibraryClient {
@@ -150,6 +198,55 @@ object LibraryClient {
}
}
/**
* What the host currently has running, from `GET /api/v1/status`.
*
* Same lane, same identity, no new host work: `/status` is already on the paired-certificate
* allowlist (the host's `mgmt::auth::cert_may_access`) alongside `/library`, and has carried a
* `games[]` array since the sessiongame lifetime work. This client simply never read it so
* a player had no way to see, from the device they browse on, that something was already up.
*
* **Best-effort by contract**: an older host, an unreachable one, or a shape we don't recognize
* yields an empty list rather than an error. Nothing here is worth failing a library screen
* over the worst case is a Resume badge that doesn't appear. BLOCKING; call from IO.
*/
fun fetchRunning(
address: String,
mgmtPort: Int = DEFAULT_MGMT_PORT,
certPem: String,
keyPem: String,
fpHex: String,
): List<RunningGame> {
if (fpHex.isBlank()) return emptyList()
return try {
val client = mtlsHttpClient(certPem, keyPem, address, fpHex)
val req = Request.Builder().url("https://$address:$mgmtPort/api/v1/status").build()
client.newCall(req).execute().use { resp ->
if (resp.code != 200) return emptyList()
parseRunning(resp.body?.string().orEmpty())
}
} catch (_: Exception) {
emptyList()
}
}
/** Just the `games[]` slice of `/status`; everything else on that payload is the console's. */
private fun parseRunning(json: String): List<RunningGame> {
val arr = JSONObject(json).optJSONArray("games") ?: return emptyList()
val out = ArrayList<RunningGame>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
out.add(
RunningGame(
appId = str(o, "app_id"),
title = o.optString("title"),
state = o.optString("state"),
),
)
}
return out
}
private fun parse(json: String, base: String): List<GameEntry> {
val arr = JSONArray(json)
val out = ArrayList<GameEntry>(arr.length())
@@ -168,6 +265,7 @@ object LibraryClient {
),
role = str(o, "role"),
icon = str(o, "icon"),
platform = str(o, "platform"),
),
)
}
@@ -0,0 +1,146 @@
package io.unom.punktfunk.kit.library
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
// On-disk cache for a host's library CATALOG — the list of titles, not their art. The Android
// mirror of the Apple client's `LibraryCache.swift` and the Rust `pf_client_core::library_cache`.
//
// Cover art has been cached by Coil for a while; the catalog behind it never was. Every visit to a
// library refetched `GET /api/v1/library` and showed a spinner until that call returned. A host
// that is asleep, or simply not reachable yet, therefore had an EMPTY library — which is the
// opposite of what a player wants from the screen they use to decide what to play, and it makes
// waking a host on library entry pointless: there would be nothing to look at while it boots.
//
// So the catalog is cached per host and rendered immediately, marked stale, and reconciled when the
// host answers.
//
// Cache directory (`context.cacheDir`), not files: every byte is re-derivable from the host, so
// Android is welcome to evict it under storage pressure. Unlike art, a catalog is small (a few
// hundred KB for a big library), so there is no size budget here — one file per host, replaced
// wholesale.
//
// Takes a plain [File] directory rather than a Context so it can be unit-tested against a temp
// dir, exactly like the Apple original.
/** A host's library as last seen, with when that was (epoch millis). */
data class CachedLibrary(val games: List<GameEntry>, val fetchedAt: Long)
class LibraryCache(private val directory: File) {
/**
* This host's last-known catalog, or null if there is no usable one.
*
* A catalog written by an older build whose [GameEntry] had different fields decodes to null
* rather than throwing: a miss costs one fetch, which is what would have happened anyway.
* Never surfaced as an error.
*/
fun load(hostKey: String): CachedLibrary? = try {
val file = pathFor(hostKey)
if (!file.isFile) {
null
} else {
val root = JSONObject(file.readText())
val arr = root.getJSONArray("games")
val games = ArrayList<GameEntry>(arr.length())
for (i in 0 until arr.length()) {
games.add(decode(arr.getJSONObject(i)))
}
CachedLibrary(games, root.optLong("fetchedAt"))
}
} catch (_: Exception) {
null
}
/**
* Remember this host's catalog. Best-effort: a cache that can't write is a slower app, not a
* broken one, so every failure here is swallowed.
*/
fun store(hostKey: String, games: List<GameEntry>) {
// An empty catalog is not worth remembering: it is indistinguishable from "never fetched"
// when read back, and caching it would pin a blank library over a host that has titles.
if (games.isEmpty()) return
try {
val root = JSONObject()
.put("fetchedAt", System.currentTimeMillis())
.put("games", JSONArray().apply { games.forEach { put(encode(it)) } })
directory.mkdirs()
// Write-then-rename: an app killed mid-write must not leave a half-file that the next
// launch reads as a corrupt catalog.
val target = pathFor(hostKey)
val tmp = File(target.parentFile, "${target.name}.tmp")
tmp.writeText(root.toString())
if (!tmp.renameTo(target)) {
tmp.delete()
}
} catch (_: Exception) {
// a cache that can't write is a slower app, not a broken one
}
}
/**
* Drop a host's catalog part of forgetting the host, so a removed host leaves no list of
* what somebody plays behind on the device.
*/
fun forget(hostKey: String) {
runCatching { pathFor(hostKey).delete() }
}
/**
* Hashed rather than used verbatim: a host key is user-controlled text (a name, an address)
* and must never be able to reach out of this directory (`../`) or exceed a filename length
* limit.
*/
private fun pathFor(hostKey: String): File {
val digest = MessageDigest.getInstance("SHA-256").digest(hostKey.toByteArray())
return File(directory, digest.joinToString("") { "%02x".format(it) } + ".json")
}
/**
* The wire shape, so a cached file and a host response decode through the same [GameEntry].
* Art URLs are stored ALREADY RESOLVED to absolute the host-relative form only means
* anything next to the base it was fetched from, and re-deriving that base on load would make
* a cache entry depend on the address the host happens to have today.
*/
private fun encode(g: GameEntry): JSONObject = JSONObject()
.put("id", g.id)
.put("store", g.store)
.put("title", g.title)
.put("art", JSONObject().apply {
g.art.portrait?.let { put("portrait", it) }
g.art.header?.let { put("header", it) }
g.art.hero?.let { put("hero", it) }
})
.apply {
g.role?.let { put("role", it) }
g.icon?.let { put("icon", it) }
g.platform?.let { put("platform", it) }
}
private fun decode(o: JSONObject): GameEntry {
val art = o.optJSONObject("art") ?: JSONObject()
return GameEntry(
id = o.optString("id"),
store = o.optString("store"),
title = o.optString("title"),
art = Artwork(
portrait = nullable(art, "portrait"),
header = nullable(art, "header"),
hero = nullable(art, "hero"),
),
role = nullable(o, "role"),
icon = nullable(o, "icon"),
platform = nullable(o, "platform"),
)
}
private fun nullable(o: JSONObject, key: String): String? =
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
companion object {
/** The app's standard location for this cache, under Android's evictable cache dir. */
fun standard(cacheDir: File): LibraryCache = LibraryCache(File(cacheDir, "punktfunk-library"))
}
}
@@ -0,0 +1,254 @@
package io.unom.punktfunk.kit
import android.view.KeyEvent
import android.view.MotionEvent
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Pure JVM test of [Gamepad.PadButtons.correct] the scancode resolution for controllers Android
* has no key layout for. Only `KeyEvent`'s compile-time-inlined keycode constants are involved, so
* no Android runtime is needed. Run: `./gradlew :kit:testDebugUnitTest`.
*
* The regression it pins is a field report from a Fire TV Stick 4K Max (2026-08-20): a DualSense
* and an Xbox Elite Series 2, both over Bluetooth, both identified correctly but with buttons
* landing on the wrong actions "L1 being L2". Neither pad has a key layout on that box (AOSP
* ships none for `045e:0b05` at all, and the DualSense's requires `CONFIG_HID_PLAYSTATION`), so
* both fall back to `Generic.kl`, which names keycodes by scancode POSITION. A pad with no kernel
* driver numbers its HID buttons 1..n straight through in its own report order, so every keycode
* after the first divergence belongs to a different button.
*
* The table below is the pad's physical button on the left and where `Generic.kl` put it on the
* right; the assertions read it back the other way.
*/
class PadButtonsTest {
private fun sony(scan: Int) =
Gamepad.PadButtons.GENERIC_SONY.correct(scan, Gamepad.genericKeyCode(scan))
private fun xbox(scan: Int) =
Gamepad.PadButtons.GENERIC_XBOX.correct(scan, Gamepad.genericKeyCode(scan))
/**
* The exact report: a DualSense's L2 sits at scancode `0x136`, which `Generic.kl` calls
* BUTTON_L1 so pulling L2 read as a shoulder press, and L1 (at `0x134`, read as BUTTON_Y)
* read as a face button.
*/
@Test
fun `a DualSense's shoulders stop being each other's buttons`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, sony(0x134)) // L1, delivered as BUTTON_Y
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, sony(0x135)) // R1, delivered as BUTTON_Z
assertEquals(KeyEvent.KEYCODE_BUTTON_L2, sony(0x136)) // L2, delivered as BUTTON_L1
assertEquals(KeyEvent.KEYCODE_BUTTON_R2, sony(0x137)) // R2, delivered as BUTTON_R1
}
/** ✕ is the bottom button — the one A means everywhere else — and □ is the left one. */
@Test
fun `a DualSense's face buttons land on their Xbox positions`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_X, sony(0x130)) // □
assertEquals(KeyEvent.KEYCODE_BUTTON_A, sony(0x131)) // ✕
assertEquals(KeyEvent.KEYCODE_BUTTON_B, sony(0x132)) // ○
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, sony(0x133)) // △
}
/**
* Create/Options/L3/R3/PS. Select in particular: without this it arrived as BUTTON_THUMBL,
* which took the exit, mic and stats chords with it every one of them is built on Select.
*/
@Test
fun `a DualSense's menu buttons and stick clicks are themselves`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, sony(0x138)) // Create
assertEquals(KeyEvent.KEYCODE_BUTTON_START, sony(0x139)) // Options
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, sony(0x13a)) // L3
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, sony(0x13b)) // R3
assertEquals(KeyEvent.KEYCODE_BUTTON_MODE, sony(0x13c)) // PS
}
/** The touchpad click and mute have no wire button; they must resolve to nothing, not to R3. */
@Test
fun `a DualSense's touchpad and mute are dropped rather than mistaken`() {
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13d))
assertEquals(KeyEvent.KEYCODE_UNKNOWN, sony(0x13e))
assertEquals(0, Gamepad.buttonBit(sony(0x13d)))
}
/** An Xbox-layout pad numbering straight through: A B X Y LB RB View Menu LS RS. */
@Test
fun `an Xbox pad numbering straight through keeps its own layout`() {
assertEquals(KeyEvent.KEYCODE_BUTTON_A, xbox(0x130))
assertEquals(KeyEvent.KEYCODE_BUTTON_B, xbox(0x131))
assertEquals(KeyEvent.KEYCODE_BUTTON_X, xbox(0x132))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, xbox(0x133))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, xbox(0x134))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, xbox(0x135))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, xbox(0x136)) // View
assertEquals(KeyEvent.KEYCODE_BUTTON_START, xbox(0x137)) // Menu
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBL, xbox(0x138))
assertEquals(KeyEvent.KEYCODE_BUTTON_THUMBR, xbox(0x139))
}
/** `hid-playstation` emits the modern Linux codes, where only the face pair reads swapped. */
@Test
fun `a driver-backed Sony pad has only its face pair corrected`() {
val m = Gamepad.PadButtons.SONY_MODERN
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, m.correct(0x133, KeyEvent.KEYCODE_BUTTON_X)) // △
assertEquals(KeyEvent.KEYCODE_BUTTON_X, m.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y)) // □
for (scan in listOf(0x130, 0x131, 0x136, 0x137, 0x13a, 0x13b, 0x13c)) {
assertEquals(Gamepad.genericKeyCode(scan), m.correct(scan, Gamepad.genericKeyCode(scan)))
}
}
/**
* The guard that makes all of this safe to run on every pad: a keycode that is NOT what
* `Generic.kl` would have said came from a device-specific key layout, which knows this
* controller better than any table here. Correcting it would break a pad that works.
*/
@Test
fun `a keycode a device layout already resolved is never second-guessed`() {
// AOSP's DualSense layout puts △ on BUTTON_Y itself. Every profile must leave it be.
for (p in Gamepad.PadButtons.entries) {
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, p.correct(0x133, KeyEvent.KEYCODE_BUTTON_Y))
}
// Same for a scancode outside the generic gamepad block entirely — a pad's Back key.
assertEquals(
KeyEvent.KEYCODE_BACK,
Gamepad.PadButtons.GENERIC_SONY.correct(158, KeyEvent.KEYCODE_BACK),
)
}
/** Correcting twice is correcting once — the output is never itself a generic-layout answer. */
@Test
fun `correction is idempotent`() {
for (p in Gamepad.PadButtons.entries) {
for (scan in 0x130..0x13e) {
val once = p.correct(scan, Gamepad.genericKeyCode(scan))
assertEquals(once, p.correct(scan, once))
}
}
}
/**
* The axis half. A pad that names its triggers something Android knows is read exactly as it
* always was this is the branch that must NOT fire on the pads that already work.
*/
@Test
fun `a pad that names its triggers is read unchanged`() {
for (p in Gamepad.PadButtons.entries) {
val map = Gamepad.padMap(p, namedTriggers = true, hasRxRy = true, restsNegative = true)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(Gamepad.AXIS_NONE, map.leftTrigger)
assertEquals(Gamepad.AXIS_NONE, map.rightTrigger)
}
// Same when there is no Rx/Ry to fall back to in the first place.
val none = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = false, restsNegative = false)
assertEquals(Gamepad.AXIS_NONE, none.leftTrigger)
}
/**
* A Sony pad reporting straight through lays out X, Y, Z, Rz, Rx, Ry left stick, right
* stick, then the triggers. Only the triggers were being missed; the sticks already read
* right and must be left alone.
*/
@Test
fun `an unmapped Sony pad keeps its sticks and gains its triggers`() {
val map = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_Z, map.rightStickX)
assertEquals(MotionEvent.AXIS_RZ, map.rightStickY)
assertEquals(MotionEvent.AXIS_RX, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RY, map.rightTrigger)
}
/**
* Every other unmapped pad is the opposite way round: right stick on Rx/Ry, triggers on Z/Rz.
* Reading Z/Rz as the right stick there is what makes pulling a trigger swing it so the two
* pairs must never be mixed up, which is the whole point of pinning them.
*/
@Test
fun `an unmapped Xbox-layout pad has its stick and triggers the other way round`() {
for (p in listOf(Gamepad.PadButtons.GENERIC_XBOX, Gamepad.PadButtons.SONY_MODERN)) {
val map = Gamepad.padMap(p, namedTriggers = false, hasRxRy = true, restsNegative = false)
assertEquals(MotionEvent.AXIS_RX, map.rightStickX)
assertEquals(MotionEvent.AXIS_RY, map.rightStickY)
assertEquals(MotionEvent.AXIS_Z, map.leftTrigger)
assertEquals(MotionEvent.AXIS_RZ, map.rightTrigger)
}
}
/**
* A trigger axis that idles at 1 is rescaled; one that idles at 0 must NOT be, or it would
* read as a permanent half-pull. Which it is gets measured off the device, never assumed
* both the DualSense's raw RX/RY and the Xbox pad's Z/Rz report an honest 0..1.
*/
@Test
fun `only a trigger that idles negative is rescaled`() {
val signed = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = true)
assertEquals(0f, signed.level(-1f), 1e-6f)
assertEquals(0.5f, signed.level(0f), 1e-6f)
assertEquals(1f, signed.level(1f), 1e-6f)
val unsigned = Gamepad.padMap(Gamepad.PadButtons.GENERIC_SONY, false, hasRxRy = true, restsNegative = false)
assertEquals(0f, unsigned.level(0f), 1e-6f)
assertEquals(1f, unsigned.level(1f), 1e-6f)
}
/** A pad Android does know is untouched, which is most of them. */
@Test
fun `a pad with a key layout is left alone`() {
for (scan in 0x130..0x13e) {
val generic = Gamepad.genericKeyCode(scan)
assertEquals(generic, Gamepad.PadButtons.NATIVE.correct(scan, generic))
}
}
/**
* The regression that made this gate necessary (field reports, 2026-08-21): an Xbox Wireless
* Controller and a GameSir G8+, both with their buttons at the standard positions and both
* corrected anyway, because `hasKeys` says BUTTON_C and BUTTON_Z for any pad that DECLARES six
* buttons `hid-input` allocates the whole descriptor `BTN_A + n` straight through whether the
* pad ever presses them or not. Naming the triggers is what tells the two apart.
*/
@Test
fun `a pad that names its triggers is never corrected, whatever it declares`() {
for (sony in listOf(false, true)) {
for (declaresCZ in listOf(false, true)) {
assertEquals(
Gamepad.PadButtons.NATIVE,
Gamepad.padButtons(namedTriggers = true, sony = sony, declaresCZ = declaresCZ),
)
}
}
}
/**
* The four buttons the field reports named, on a pad whose report order is already standard:
* X answering Y, Y answering LB, and both shoulders answering a menu button. NATIVE is what
* keeps them themselves the correction tables are right for the pads they are for, and this
* is about not reaching one of them.
*/
@Test
fun `an Xbox pad at the standard positions keeps X, Y and its shoulders`() {
val native = Gamepad.PadButtons.NATIVE
assertEquals(KeyEvent.KEYCODE_BUTTON_X, native.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, native.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, native.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_R1, native.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
// What the old heuristic did to each of them, kept here so the difference stays visible.
val wrong = Gamepad.PadButtons.GENERIC_XBOX
assertEquals(KeyEvent.KEYCODE_BUTTON_Y, wrong.correct(0x133, KeyEvent.KEYCODE_BUTTON_X))
assertEquals(KeyEvent.KEYCODE_BUTTON_L1, wrong.correct(0x134, KeyEvent.KEYCODE_BUTTON_Y))
assertEquals(KeyEvent.KEYCODE_BUTTON_SELECT, wrong.correct(0x136, KeyEvent.KEYCODE_BUTTON_L1))
assertEquals(KeyEvent.KEYCODE_BUTTON_START, wrong.correct(0x137, KeyEvent.KEYCODE_BUTTON_R1))
}
/** Past the gate, which straight-through order to read is still the question it always was. */
@Test
fun `an unnamed-trigger pad still resolves its report order`() {
fun order(sony: Boolean, declaresCZ: Boolean) =
Gamepad.padButtons(namedTriggers = false, sony = sony, declaresCZ = declaresCZ)
assertEquals(Gamepad.PadButtons.GENERIC_SONY, order(sony = true, declaresCZ = true))
assertEquals(Gamepad.PadButtons.GENERIC_XBOX, order(sony = false, declaresCZ = true))
assertEquals(Gamepad.PadButtons.SONY_MODERN, order(sony = true, declaresCZ = false))
assertEquals(Gamepad.PadButtons.NATIVE, order(sony = false, declaresCZ = false))
}
}
@@ -0,0 +1,67 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The truth table behind "is a controller attached" the question the console UI's
* "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the
* console UI on with no pad in the room, and no setting short of turning the whole thing off can
* dismiss it, because the phantom pad never disconnects.
*/
class PadPresenceTest {
/** A real pad: the source class plus hardware behind it, in either of the two shapes. */
@Test
fun realPadsCount() {
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
// An arcade stick / d-pad-only pad — buttons, no analog stick.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = true,
),
)
// A wheel or flight stick — axes, no A/B.
assertTrue(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = true, hasFaceButtons = false,
),
)
}
/** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the
* gamepad source class. This is the field report the console UI that could not be dismissed. */
@Test
fun virtualDevicesAreNotControllers() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = true, hasStick = true, hasFaceButtons = true,
),
)
}
/** A device that claims a pad source with nothing behind it is not a pad either. */
@Test
fun aSourceClaimWithoutHardwareIsNotAController() {
assertFalse(
Gamepad.looksLikeController(
padSource = true, virtual = false, hasStick = false, hasFaceButtons = false,
),
)
}
/** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */
@Test
fun nonPadSourcesNeverCount() {
assertFalse(
Gamepad.looksLikeController(
padSource = false, virtual = false, hasStick = true, hasFaceButtons = true,
),
)
}
}
+24
View File
@@ -92,5 +92,29 @@ opus = "0.3"
uac-host = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
usbfs-iso = { git = "https://github.com/unom-io/usbfs-iso", rev = "f3de1fd62cec271d07f45664dc464f23e423e721" }
# The Skia console UI (design/android-skia-console-port.md): the SAME `pf-console-ui` shell the
# Linux/Windows session binary shows, drawn here through Skia's GL backend on an EGL surface
# (`src/console/`). `default-features = false` turns its Vulkan overlay off — this host owns the
# surface. `pf-client-core` for the portable types the shell speaks (settings, menu events,
# overlay actions); `default-features = false` like every other consumer (its default is the
# PyroWave decoder, C++ nobody here wants).
#
# All three shipping ABIs. rust-skia publishes prebuilt Skia archives for aarch64/x86_64/
# i686-linux-android but NOT armv7-linux-androideabi (the 32-bit TV boxes) — that one comes from
# OUR mirror through `SKIA_BINARIES_URL` (kit/build.gradle.kts forwards it; the CI variable of
# the same name; sha256 table beside it), built once with skia-bindings itself (design
# android-skia-console-port.md WP6). 🛑 skia-bindings does not fail when no archive matches — it
# silently builds Skia from source — so an armeabi-v7a build WITHOUT the mirror url is a
# multi-minute source build, and every ABI's log must show `DOWNLOAD AND INSTALL SUCCEEDED`.
# (Continues the `cfg(target_os = "android")` table above — TOML allows a table header once.)
pf-console-ui = { path = "../../../crates/pf-console-ui", default-features = false }
pf-client-core = { path = "../../../crates/pf-client-core", default-features = false }
# `gl` + `textlayout` = the prebuilt key `gl-jpegd-jpege-pdf-textlayout` (verify
# `DOWNLOAD AND INSTALL SUCCEEDED` in the build log on every bump — see pf-console-ui/Cargo.toml).
skia-safe = { version = "0.99", features = ["gl", "textlayout"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+432
View File
@@ -0,0 +1,432 @@
//! The audio format a session RESOLVED, and the millisecond ⇄ interleaved-sample arithmetic every
//! figure the playback plane reports is expressed in.
//!
//! **Why this is its own module, and why it is NOT `#[cfg(target_os = "android")]` like the
//! [`crate::audio`] that owns it.** The conversions below are the part of the plane that was
//! *wrong* — see [`ms_to_samples`] — and the whole class of defect is one that measures cleanly
//! while being off by a fixed percentage. A bug like that is only ever caught by arithmetic tests,
//! and an arithmetic test that can only run on a phone is a test that runs when someone remembers.
//! Nothing here touches AAudio, so nothing here needs a device: it compiles and is tested on the
//! ordinary `cargo test -p punktfunk-client-android --lib` leg, and `:kit:cargoNdkClippy` lints it
//! at both Android widths on top.
use punktfunk_core::audio::pcm;
// Only [`SessionAudio::of`] touches the connector, and only on device — see the `cfg` there.
#[cfg(target_os = "android")]
use punktfunk_core::client::NativeClient;
/// The `0xC9` plane's frame duration: fixed by the protocol at 5 ms (the host's `audio_thread`),
/// not negotiated. Only `0xD3` carries `audio_frame_us`.
pub(crate) const OPUS_FRAME_US: u32 = 5_000;
// ---- ms ⇄ interleaved samples: multiply FIRST, divide LAST ------------------------------------
//
// This mirrors `punktfunk_core::audio`'s own pair, and it mirrors it because the defect it fixes
// was copied from there. Both used to precompute `per_ms = rate_hz / 1000 * channels` and express
// every ms-denominated figure as `ms * per_ms`. **That division happens first**, so 44 100 Hz
// became 44 samples per millisecond and every depth, hard cap and reported `buffer_ms` was 2.3 %
// out — quietly, permanently, and only on the rates the old ladder happened not to offer. 48 000
// and 96 000 were exact by luck: they divide.
//
// Keeping the rate and the channel count as the two numbers they are, and dividing last, is exact
// at every rate on `pcm::rate_is_supported`'s ladder for one integer division per conversion, and
// 48/96 kHz stay bit-identical by construction (`per_sec == 1000 × per_ms` exactly there, so both
// conversions reduce to the expression they replace).
/// Interleaved samples per second at a negotiated layout — the denominator both conversions share.
///
/// `max(1)` on both factors: a degenerate layout must not divide by zero on a realtime thread.
/// [`SessionAudio::of`] already clamps, so this is the belt to that pair of braces.
fn interleaved_per_sec(rate_hz: u32, channels: usize) -> u64 {
let hz = if rate_hz == 0 { 1 } else { rate_hz } as u64;
let ch = if channels == 0 { 1 } else { channels } as u64;
hz * ch
}
/// `ms` milliseconds of audio, in interleaved samples.
///
/// u64 intermediates because the product is large where a `usize` may be 32 bits — and on this
/// client that is not hypothetical: **armeabi-v7a is a shipping ABI** (every 32-bit Google TV /
/// Android TV box), so the same expression runs at both widths. `JitterTuning::AAUDIO`'s hard cap
/// against 176 400 Hz × 8 ch would be fine, but the type is what makes that a fact rather than an
/// audit. Saturating rather than wrapping, because a wrapped window is a *tiny* one — a buffer cap
/// that is instantly exceeded instead of one that is never reached.
fn ms_to_samples(rate_hz: u32, channels: usize, ms: u32) -> usize {
let n = ms as u64 * interleaved_per_sec(rate_hz, channels) / 1000;
if n > u32::MAX as u64 {
u32::MAX as usize
} else {
n as usize
}
}
/// Interleaved samples back to whole milliseconds — the exact inverse of [`ms_to_samples`].
///
/// u128 because `samples` arrives from a ring depth and nothing bounds it: `usize::MAX * 1000`
/// overflows a u64 on the 64-bit ABI.
fn samples_to_ms(rate_hz: u32, channels: usize, samples: usize) -> u32 {
let ms = samples as u128 * 1000 / interleaved_per_sec(rate_hz, channels) as u128;
if ms > u32::MAX as u128 {
u32::MAX
} else {
ms as u32
}
}
/// The audio format this session RESOLVED, read once from the connector and threaded through the
/// whole plane.
///
/// Gathered into one value rather than passed as five parameters because the fields are only
/// meaningful together: a rate without the codec cannot tell a 48 kHz lossless session from a
/// 48 kHz Opus one, and those two agree on every other resolved value.
///
/// ⚠ Everything here is what the HOST resolved, never what this client asked for. A client that
/// requests 96 kHz, is answered 48 kHz and opens at 96 kHz anyway is `design/hi-res-audio.md`
/// §4.3's failure one end further along — a session that audits clean at both ends and plays the
/// wrong content.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct SessionAudio {
/// [`punktfunk_core::quic::AUDIO_CODEC_OPUS`] (`0xC9`) or
/// [`punktfunk_core::quic::AUDIO_CODEC_PCM`] (`0xD3`) — what SELECTS the decoder, and the only
/// field that can.
pub(crate) codec: u8,
/// The resolved sample rate: 48 000 on every Opus session, and any rung of
/// [`pcm::rate_is_supported`] on `0xD3` — 44 100 / 48 000 / 88 200 / 96 000 / 176 400. Both
/// families are exact in every conversion this module performs; the 44.1 one was deferred
/// only for as long as the arithmetic above divided before it multiplied.
pub(crate) rate_hz: u32,
/// The resolved sample depth (16 or 24) — the stride `0xD3` payloads are unpacked at.
/// Meaningless on the Opus plane, which decodes to f32 regardless.
pub(crate) bits: u8,
/// The resolved, normalized channel count (2 / 6 / 8).
///
/// ⚠ Not "2" any more on the lossless plane. Surround was excluded from `0xD3` because a 5.1
/// frame did not fit a datagram at the default MTU, but the ladder is channel-aware and the
/// restriction was one host-side condition, not a wire limitation: at the conservative
/// datagram size a 48 kHz/16-bit 5.1 session negotiates 2 ms frames and a 24-bit one 1 ms
/// (a thousand datagrams a second), while 96/24 5.1 still fits nothing and is declined. Every
/// per-frame size below is taken from THIS count for that reason.
pub(crate) channels: usize,
/// How much audio one datagram carries. Negotiated from the path MTU on `0xD3` (at 96 kHz /
/// 24-bit stereo the default MTU ceiling only leaves room for 2 ms, and for 24-bit surround
/// 1 ms), and the Opus plane's fixed 5 ms otherwise — folded to one field here so nothing
/// downstream has to branch to size a buffer.
///
/// ⚠ A **label**, not a duration. It is a whole number of samples per channel only when the
/// rate divides the rung, which the 44.1 kHz family never does: a nominal 5 ms frame at
/// 44 100 Hz carries 220 samples per channel = 4 988 662 ns. Size from [`Self::frame_samples`];
/// time from [`pcm::frame_duration_ns`].
pub(crate) frame_us: u32,
}
impl SessionAudio {
/// Read the whole resolved format off the connector, once, at the top of the plane.
///
/// Android-only, because a [`NativeClient`] only exists once a session has been negotiated on
/// a device — the pure half is [`resolved`](Self::resolved), which is where the clamping lives
/// and what the tests exercise. Left ungated it would be dead code on the host build, and
/// `-D warnings` is a hard gate there.
#[cfg(target_os = "android")]
pub(crate) fn of(client: &NativeClient) -> SessionAudio {
SessionAudio::resolved(
client.audio_codec,
client.audio_sample_rate_hz,
client.audio_bits,
client.audio_channels,
u32::from(client.audio_frame_us),
)
}
/// The `Welcome`'s five audio fields, clamped into something every buffer below can be sized
/// from. **Every value here arrives off the wire**, so each clamp is defending a realtime
/// thread against a host that is old, wrong, or hostile — none of them bite a conforming one.
pub(crate) fn resolved(
codec: u8,
rate_hz: u32,
bits: u8,
channels: u8,
frame_us: u32,
) -> SessionAudio {
let is_pcm = codec == punktfunk_core::quic::AUDIO_CODEC_PCM;
// A zero rate is inexpressible off the wire (`Welcome::decode` folds both absence and a
// literal 0 to the legacy 48 kHz), but it is the denominator of every conversion above,
// so a 0 that ever DID reach here would be a division by zero on the decode thread. One
// clamp, at the one place the value enters this module.
let rate_hz = if rate_hz == 0 {
punktfunk_core::audio::SAMPLE_RATE_HZ
} else {
rate_hz
};
SessionAudio {
codec,
rate_hz,
bits,
channels: punktfunk_core::audio::normalize_channels(channels) as usize,
// Same reasoning as the rate: an old host sends no `audio_frame_us` at all and a
// hostile one could send 0, and this number divides nothing but sizes everything.
//
// Capped at the longest rung of `FRAME_US_LADDER` (which is also the Opus plane's
// 5 ms) because the decode scratch is sized from that rung and clamps its copies to
// it: an unclamped `frame_us` would let a `Welcome` claim frames the scratch cannot
// hold, and the ring would then be reserved for a size the loop can never deliver.
// A conforming host only ever names a rung, so this bites nobody real.
frame_us: match (is_pcm, frame_us) {
(true, us) if us > 0 => us.min(pcm::FRAME_US_LADDER[0]),
// The Opus plane's frames are the protocol's fixed 5 ms (host `audio_thread`).
_ => OPUS_FRAME_US,
},
}
}
/// True when this session runs the lossless `0xD3` plane rather than Opus on `0xC9`.
pub(crate) fn is_pcm(&self) -> bool {
self.codec == punktfunk_core::quic::AUDIO_CODEC_PCM
}
/// `ms` of audio in interleaved samples at this session's layout — see [`ms_to_samples`].
pub(crate) fn ms_samples(&self, ms: u32) -> usize {
ms_to_samples(self.rate_hz, self.channels, ms)
}
/// The inverse, for the depths this plane reports to the HUD — see [`samples_to_ms`].
pub(crate) fn samples_ms(&self, samples: usize) -> u32 {
samples_to_ms(self.rate_hz, self.channels, samples)
}
/// Interleaved samples in ONE frame of this plane — what the ring reserves per queued chunk
/// and what the decode-scratch assertion is written against.
///
/// Taken from [`pcm::samples_per_frame`] rather than re-derived here, because that function is
/// the single source of truth for how long a frame is and the host fills its buffers from it.
/// The two are only interchangeable when the rate divides the rung: **5 ms of audio at
/// 44 100 Hz stereo is 441 interleaved samples, but a 5 ms FRAME carries 440** — 220.5 samples
/// per channel do not exist, so the wire floors. Both the ring reserve and the debug assertion
/// that guards it mean "exactly one packet", and a self-derived answer would describe a packet
/// no host ever sends.
pub(crate) fn frame_samples(&self) -> usize {
pcm::samples_per_frame(self.rate_hz, self.frame_us, self.channels as u8)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every rate the lossless plane admits, so a conversion that is only exact on one family can
/// never be pinned by accident.
const RATES: [u32; 5] = [44_100, 48_000, 88_200, 96_000, 176_400];
fn fmt(rate_hz: u32, channels: usize, frame_us: u32) -> SessionAudio {
SessionAudio {
codec: punktfunk_core::quic::AUDIO_CODEC_PCM,
rate_hz,
bits: pcm::BITS_24,
channels,
frame_us,
}
}
/// **The defect, stated as numbers.** `per_ms = rate_hz / 1000 * channels` truncates 44 100 Hz
/// stereo to 88 samples per millisecond where it is really 88.2, so everything the plane sizes
/// or reports in milliseconds came out 2.3 % wrong: the ring's hard cap 2.3 % SHALLOW, and the
/// `buffer_ms` on the HUD 2.3 % DEEP — a plane that measures itself cleanly while being off in
/// both directions at once.
///
/// Paired with [`the_48_khz_family_is_bit_identical_to_the_arithmetic_it_replaced`], which is
/// the other half of the claim and passes under BOTH expressions. Restore
/// `ms × (rate_hz / 1000 × channels)` and exactly one of the two fails; that asymmetry is the
/// whole point, and it is why they are two tests rather than one.
#[test]
fn the_ms_conversions_are_exact_on_the_rates_that_do_not_divide() {
// 44 100 × 2 = 88 200 interleaved samples a second; one second of them is 88 200, not the
// 88 000 an integer samples-per-millisecond would have claimed.
let f = fmt(44_100, 2, 5_000);
assert_eq!(f.ms_samples(1_000), 88_200);
assert_eq!(f.samples_ms(88_200), 1_000);
// …and the truncated pair, spelled out, so the size of the error is on the record rather
// than in a commit message: 88 000 samples and 1 002 ms are what the old code produced.
assert_ne!(f.ms_samples(1_000), 1_000 * 88);
assert_ne!(f.samples_ms(88_200), 88_200 / 88);
// 5.1 at 88 200 Hz — the two axes that used to be folded into one constant, both moving.
let s = fmt(88_200, 6, 2_000);
assert_eq!(s.ms_samples(100), 52_920);
assert_eq!(s.samples_ms(52_920), 100);
// And the top of the ladder, where the truncation is smallest in relative terms and still
// wrong: 176 400 Hz × 8 ch is 1 411 200 samples a second, not 1 408 000.
let top = fmt(176_400, 8, 1_000);
assert_eq!(top.ms_samples(1_000), 1_411_200);
}
/// The other half of the same claim: on 48 000 and 96 000 Hz the new conversions are
/// **bit-identical** to the `ms × (rate_hz / 1000 × channels)` they replaced, at every layout
/// and every figure the tuning names.
///
/// Load-bearing, not decorative. Every session anyone has ever run is on this family, and the
/// value of "we fixed the arithmetic" depends entirely on nobody's ring having moved by a
/// sample while we did it. It holds by construction — `rate × ch` is exactly `1000 × per_ms`
/// where the rate divides — and this is that construction asserted rather than argued.
///
/// It also passes under the OLD expression, which is what makes its partner above a real test:
/// plant `per_ms` back and this one still goes green.
#[test]
fn the_48_khz_family_is_bit_identical_to_the_arithmetic_it_replaced() {
for rate in [48_000u32, 96_000] {
for ch in [2usize, 6, 8] {
let f = fmt(rate, ch, 5_000);
let per_ms = (rate as usize / 1000) * ch;
for ms in [1u32, 2, 12, 25, 47, 120, 1_000] {
assert_eq!(
f.ms_samples(ms),
ms as usize * per_ms,
"{rate} Hz/{ch}ch must be unchanged at {ms} ms"
);
assert_eq!(
f.samples_ms(ms as usize * per_ms),
ms,
"{rate} Hz/{ch}ch must read back unchanged at {ms} ms"
);
}
// A depth that is NOT a whole number of milliseconds truncates the same way it
// always did — the reported `buffer_ms` never rounds up into a figure the ring
// does not hold.
assert_eq!(f.samples_ms(per_ms * 12 + per_ms / 2), 12);
}
}
}
/// The round trip `design/hi-res-audio.md` §4.1 names as the tell that this rework is
/// incomplete: a depth expressed in samples and read back as milliseconds must be the
/// milliseconds it was built from, at every rate on the ladder and every layout the plane can
/// resolve. Core asserts the same property for [`punktfunk_core::audio::JitterPolicy`]; this
/// is the half of it this client owns, since the ring's cap and the HUD's `buffer_ms` are
/// converted here rather than there.
#[test]
fn the_shipping_ladder_round_trips_ms_to_samples_at_every_rate() {
let t = punktfunk_core::audio::JitterTuning::AAUDIO;
for rate in RATES {
for ch in [2usize, 6, 8] {
let f = fmt(rate, ch, 2_000);
// Every ms figure this preset names — each is a threshold something compares a
// sample count against, and a rate that skewed 2.3 % skewed all of them together,
// which is exactly what kept the defect invisible.
for ms in [
t.base_target_ms,
t.max_target_ms,
t.headroom_ms,
t.hard_cap_ms,
t.deprime_ms,
t.plc_max_ms(),
] {
assert_eq!(
f.samples_ms(f.ms_samples(ms)),
ms,
"{rate} Hz/{ch}ch lost {ms} ms on the round trip"
);
}
// The conversion itself, against the arithmetic done the honest way rather than
// against itself: multiply by the rate and the channels, and only THEN divide.
for ms in [1u32, 2, 12, 47, 1_000, 480_000] {
assert_eq!(
f.ms_samples(ms) as u64,
ms as u64 * rate as u64 * ch as u64 / 1000,
"{ms} ms at {rate} Hz/{ch}ch"
);
}
}
}
// ⚠ Exact is not the same as lossless in both directions, and the difference is worth
// stating rather than discovering. A millisecond is 88.2 samples at 44 100 Hz stereo, so
// an ms figure that is not a multiple of 5 has no whole-sample answer at all: 1 ms floors
// to 88 samples, which reads back as 0. That is a floor of at most ONE SAMPLE — against
// the 2.3 % the old arithmetic was out by on EVERY figure, in the same direction,
// permanently. Every threshold `JitterTuning::AAUDIO` names is a multiple of 5, which is
// why the loop above is exact and this note is a note.
let f = fmt(44_100, 2, 5_000);
assert_eq!(f.ms_samples(1), 88); // the true 88.2, floored
assert_eq!(f.samples_ms(88), 0);
assert_eq!(f.ms_samples(15), 1_323, "15 ms of 44.1 kHz stereo");
assert_eq!(15 * (44_100 / 1000) * 2, 1_320, "what it used to compute");
}
/// **A frame is not the milliseconds it is labelled with.** At 44 100 Hz a nominal 5 ms frame
/// carries 220 samples per channel — 440 interleaved — while 5 ms of *audio* is 441, because
/// 220.5 samples per channel do not exist and the wire floors.
///
/// The ring reserve, the decode-scratch assertion and the policy's shed all mean "exactly one
/// packet", so this must come from [`pcm::samples_per_frame`] — the same function the host
/// fills its buffers from — and never from a millisecond count. One sample of disagreement on
/// an interleaved stream walks the channels around each other.
#[test]
fn a_frame_is_the_wires_sample_count_not_the_labels_milliseconds() {
let f = fmt(44_100, 2, 5_000);
assert_eq!(f.frame_samples(), 440, "220 samples per channel, floored");
assert_eq!(f.ms_samples(5), 441, "5 ms of AUDIO is 441 interleaved");
assert_ne!(f.frame_samples(), f.ms_samples(5));
// The real duration of that frame, which is what a `pts_ns` must advance by — 0.23 % short
// of the label it negotiated.
assert_eq!(pcm::frame_duration_ns(440, 44_100, 2), 4_988_662);
// Where the rate divides the rung the two agree, which is why nothing noticed for as long
// as the ladder was 48/96 kHz only.
for rate in [48_000u32, 96_000] {
for ch in [2usize, 6, 8] {
let f = fmt(rate, ch, 5_000);
assert_eq!(f.frame_samples(), f.ms_samples(5), "{rate} Hz/{ch}ch");
}
}
// Surround sizes from the RESOLVED channel count, not from a stereo assumption: a 5.1
// frame is three times a stereo one and the ring is reserved from it.
let stereo = fmt(48_000, 2, 2_000);
let five_one = fmt(48_000, 6, 2_000);
assert_eq!(stereo.frame_samples(), 192);
assert_eq!(five_one.frame_samples(), 576);
}
/// A `Welcome` this client cannot trust must not become a division fault or a buffer sized
/// from garbage on the decode thread. Absence, a literal zero and an over-long frame all have
/// defined answers, and they are the safe ones.
#[test]
fn a_degenerate_welcome_clamps_instead_of_dividing_by_zero() {
const OPUS: u8 = punktfunk_core::quic::AUDIO_CODEC_OPUS;
const PCM: u8 = punktfunk_core::quic::AUDIO_CODEC_PCM;
// The ordinary session: a pre-lossless host sends none of these fields, and every absent
// one has to land on exactly what the plane has always been.
let legacy = SessionAudio::resolved(OPUS, 0, 0, 0, 0);
assert!(!legacy.is_pcm());
assert_eq!(legacy.rate_hz, punktfunk_core::audio::SAMPLE_RATE_HZ);
assert_eq!(legacy.channels, 2);
assert_eq!(legacy.frame_us, OPUS_FRAME_US);
// `audio_frame_us` is a `0xD3` field and must not be honoured on the Opus plane, whose
// frames the protocol fixes at 5 ms — a host that sent one anyway would otherwise resize
// this client's ring for frames it never sends.
assert_eq!(
SessionAudio::resolved(OPUS, 48_000, 16, 2, 2_000).frame_us,
OPUS_FRAME_US
);
// …and on `0xD3` a frame longer than the ladder's top rung is capped there, because the
// decode scratch is sized from that rung and clamps its copies to it.
let overlong = SessionAudio::resolved(PCM, 96_000, 24, 6, 60_000);
assert!(overlong.is_pcm());
assert_eq!(overlong.frame_us, pcm::FRAME_US_LADDER[0]);
assert_eq!(overlong.channels, 6);
// A layout off the wire is normalized rather than trusted: 3 channels is not a layout the
// decoder or AAudio can be opened with.
assert_eq!(
SessionAudio::resolved(PCM, 44_100, 24, 3, 5_000).channels,
2
);
// The conversions still have to survive a 0 that reached them some other way, because they
// run on a realtime-adjacent thread that may not panic.
let broken = fmt(0, 0, 0);
assert_eq!(broken.ms_samples(10), 0);
assert_eq!(broken.samples_ms(480), 480_000);
assert_eq!(broken.frame_samples(), 0);
}
}
+298
View File
@@ -0,0 +1,298 @@
//! The dozen EGL entry points the console host needs, hand-declared. `libEGL.so` is already
//! a `NEEDED` of this `.so` (skia-bindings links it for Skia's GL backend), so a plain
//! `#[link]` costs nothing new; a binding crate would be a dependency for twelve functions
//! whose signatures have not changed since 2008.
//!
//! One display, one context, one window surface at a time: the console draws into the
//! `SurfaceView` Kotlin hands over, and re-creates only the surface when that view comes and
//! goes. The context (and Skia's `DirectContext` on it) survives across surfaces so the
//! poster/glyph caches survive a trip through the stream.
use anyhow::{anyhow, bail, Result};
use std::ffi::c_void;
pub(super) type EGLDisplay = *mut c_void;
pub(super) type EGLConfig = *mut c_void;
pub(super) type EGLContext = *mut c_void;
pub(super) type EGLSurface = *mut c_void;
type EGLNativeWindowType = *mut c_void;
type EGLBoolean = u32;
type EGLint = i32;
const EGL_DEFAULT_DISPLAY: *mut c_void = std::ptr::null_mut();
const EGL_NO_DISPLAY: EGLDisplay = std::ptr::null_mut();
const EGL_NO_CONTEXT: EGLContext = std::ptr::null_mut();
const EGL_NO_SURFACE: EGLSurface = std::ptr::null_mut();
const EGL_TRUE: EGLBoolean = 1;
const EGL_NONE: EGLint = 0x3038;
const EGL_SURFACE_TYPE: EGLint = 0x3033;
const EGL_WINDOW_BIT: EGLint = 0x0004;
const EGL_RENDERABLE_TYPE: EGLint = 0x3040;
const EGL_OPENGL_ES2_BIT: EGLint = 0x0004;
const EGL_OPENGL_ES3_BIT: EGLint = 0x0040;
const EGL_RED_SIZE: EGLint = 0x3024;
const EGL_GREEN_SIZE: EGLint = 0x3023;
const EGL_BLUE_SIZE: EGLint = 0x3022;
const EGL_ALPHA_SIZE: EGLint = 0x3021;
const EGL_STENCIL_SIZE: EGLint = 0x3026;
const EGL_DEPTH_SIZE: EGLint = 0x3025;
const EGL_SAMPLES: EGLint = 0x3031;
const EGL_CONTEXT_CLIENT_VERSION: EGLint = 0x3098;
const EGL_WIDTH: EGLint = 0x3057;
const EGL_HEIGHT: EGLint = 0x3056;
#[link(name = "EGL")]
unsafe extern "C" {
fn eglGetDisplay(display_id: *mut c_void) -> EGLDisplay;
fn eglInitialize(dpy: EGLDisplay, major: *mut EGLint, minor: *mut EGLint) -> EGLBoolean;
fn eglChooseConfig(
dpy: EGLDisplay,
attrib_list: *const EGLint,
configs: *mut EGLConfig,
config_size: EGLint,
num_config: *mut EGLint,
) -> EGLBoolean;
fn eglGetConfigAttrib(
dpy: EGLDisplay,
config: EGLConfig,
attribute: EGLint,
value: *mut EGLint,
) -> EGLBoolean;
fn eglCreateContext(
dpy: EGLDisplay,
config: EGLConfig,
share_context: EGLContext,
attrib_list: *const EGLint,
) -> EGLContext;
fn eglCreateWindowSurface(
dpy: EGLDisplay,
config: EGLConfig,
win: EGLNativeWindowType,
attrib_list: *const EGLint,
) -> EGLSurface;
fn eglMakeCurrent(
dpy: EGLDisplay,
draw: EGLSurface,
read: EGLSurface,
ctx: EGLContext,
) -> EGLBoolean;
fn eglSwapBuffers(dpy: EGLDisplay, surface: EGLSurface) -> EGLBoolean;
fn eglSwapInterval(dpy: EGLDisplay, interval: EGLint) -> EGLBoolean;
fn eglQuerySurface(
dpy: EGLDisplay,
surface: EGLSurface,
attribute: EGLint,
value: *mut EGLint,
) -> EGLBoolean;
fn eglDestroySurface(dpy: EGLDisplay, surface: EGLSurface) -> EGLBoolean;
fn eglDestroyContext(dpy: EGLDisplay, ctx: EGLContext) -> EGLBoolean;
fn eglGetError() -> EGLint;
}
/// The GL client version the context was created for — Skia's `Interface::new_native()`
/// discovers the rest itself, but the SkSL mesh backdrop compiles under ES2 restrictions on
/// a 2.0 context, and the host wants to know which world it is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum GlesVersion {
Es2,
Es3,
}
/// The display + config + context, created once per host and kept for its lifetime.
pub(super) struct EglContext {
display: EGLDisplay,
config: EGLConfig,
context: EGLContext,
pub(super) version: GlesVersion,
/// The config's stencil depth — what Skia's `BackendRenderTarget` for FBO 0 declares.
pub(super) stencil_bits: i32,
/// The config's MSAA sample count (0 = none) — likewise.
pub(super) samples: i32,
}
// SAFETY: EGL handles are process-wide tokens; the render thread is the only thread that
// makes the context current, and `EglContext` is only ever moved onto it (never shared).
unsafe impl Send for EglContext {}
impl EglContext {
/// Initialise the default display and create an ES 3 context, falling back to ES 2 on
/// the boxes that have nothing newer. RGBA8888 with an 8-bit stencil (Skia's path
/// rendering wants one), no depth, no MSAA (the shell anti-aliases in Skia).
pub(super) fn new() -> Result<EglContext> {
// SAFETY: plain EGL calls with valid arguments; every handle is checked before use.
unsafe {
let display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if display == EGL_NO_DISPLAY {
bail!("eglGetDisplay: no default display");
}
let (mut major, mut minor) = (0, 0);
if eglInitialize(display, &mut major, &mut minor) != EGL_TRUE {
bail!("eglInitialize: 0x{:x}", eglGetError());
}
for (version, renderable) in [
(GlesVersion::Es3, EGL_OPENGL_ES3_BIT),
(GlesVersion::Es2, EGL_OPENGL_ES2_BIT),
] {
let attribs = [
EGL_SURFACE_TYPE,
EGL_WINDOW_BIT,
EGL_RENDERABLE_TYPE,
renderable,
EGL_RED_SIZE,
8,
EGL_GREEN_SIZE,
8,
EGL_BLUE_SIZE,
8,
EGL_ALPHA_SIZE,
8,
EGL_STENCIL_SIZE,
8,
EGL_DEPTH_SIZE,
0,
EGL_NONE,
];
let mut config: EGLConfig = std::ptr::null_mut();
let mut n: EGLint = 0;
if eglChooseConfig(display, attribs.as_ptr(), &mut config, 1, &mut n) != EGL_TRUE
|| n < 1
{
continue;
}
let client_version = match version {
GlesVersion::Es3 => 3,
GlesVersion::Es2 => 2,
};
let ctx_attribs = [EGL_CONTEXT_CLIENT_VERSION, client_version, EGL_NONE];
let context =
eglCreateContext(display, config, EGL_NO_CONTEXT, ctx_attribs.as_ptr());
if context == EGL_NO_CONTEXT {
continue;
}
let attr = |a: EGLint| {
let mut v: EGLint = 0;
if eglGetConfigAttrib(display, config, a, &mut v) == EGL_TRUE {
v
} else {
0
}
};
let stencil_bits = attr(EGL_STENCIL_SIZE);
let samples = attr(EGL_SAMPLES);
log::info!(
"console: EGL {major}.{minor}, GLES {client_version} context, stencil {stencil_bits}, samples {samples}"
);
return Ok(EglContext {
display,
config,
context,
version,
stencil_bits,
samples,
});
}
bail!(
"no EGL config/context for GLES 3 or 2 (0x{:x})",
eglGetError()
)
}
}
/// A window surface over `window` (an `ANativeWindow*`), made current on the calling
/// thread with a vsync-locked swap interval. Returns the surface and its pixel size.
pub(super) fn window_surface(&self, window: *mut c_void) -> Result<EglSurface> {
// SAFETY: `window` is a live ANativeWindow the caller holds a reference to for the
// surface's lifetime; the display/config/context are this object's own.
unsafe {
let surface =
eglCreateWindowSurface(self.display, self.config, window, std::ptr::null());
if surface == EGL_NO_SURFACE {
bail!("eglCreateWindowSurface: 0x{:x}", eglGetError());
}
if eglMakeCurrent(self.display, surface, surface, self.context) != EGL_TRUE {
let e = eglGetError();
eglDestroySurface(self.display, surface);
bail!("eglMakeCurrent: 0x{e:x}");
}
// 1 = present on the panel's cadence, never faster: `eglSwapBuffers` blocks and
// paces the render loop, which is the whole frame-timing story of this host.
eglSwapInterval(self.display, 1);
let (mut w, mut h) = (0, 0);
eglQuerySurface(self.display, surface, EGL_WIDTH, &mut w);
eglQuerySurface(self.display, surface, EGL_HEIGHT, &mut h);
Ok(EglSurface {
display: self.display,
surface,
width: w.max(1) as u32,
height: h.max(1) as u32,
})
}
}
/// Release the current surface from this thread (before the surface is destroyed).
pub(super) fn release_current(&self) {
// SAFETY: valid display; NO_SURFACE/NO_CONTEXT is the documented "unbind" call.
unsafe {
eglMakeCurrent(self.display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
}
}
}
impl Drop for EglContext {
fn drop(&mut self) {
// SAFETY: the context is this object's own and nothing is current on this thread once
// the host has released its surface (the render loop releases before it exits).
unsafe {
eglDestroyContext(self.display, self.context);
}
}
}
/// One window surface. Dropping it destroys the EGL surface — the caller must have released
/// it from the current thread first ([`EglContext::release_current`]).
pub(super) struct EglSurface {
display: EGLDisplay,
surface: EGLSurface,
pub(super) width: u32,
pub(super) height: u32,
}
// SAFETY: as for `EglContext` — moved onto the render thread, never shared.
unsafe impl Send for EglSurface {}
impl EglSurface {
/// Present. `Err` = the surface is gone underneath us (the window was destroyed) — the
/// caller drops it and waits for the next one.
pub(super) fn swap(&self) -> Result<()> {
// SAFETY: valid display + surface owned by this object.
if unsafe { eglSwapBuffers(self.display, self.surface) } == EGL_TRUE {
Ok(())
} else {
// SAFETY: plain query.
Err(anyhow!("eglSwapBuffers: 0x{:x}", unsafe { eglGetError() }))
}
}
/// Re-read the surface's pixel size (after a `surfaceChanged`).
pub(super) fn refresh_size(&mut self) {
let (mut w, mut h) = (0, 0);
// SAFETY: valid display + surface owned by this object.
unsafe {
eglQuerySurface(self.display, self.surface, EGL_WIDTH, &mut w);
eglQuerySurface(self.display, self.surface, EGL_HEIGHT, &mut h);
}
self.width = w.max(1) as u32;
self.height = h.max(1) as u32;
}
}
impl Drop for EglSurface {
fn drop(&mut self) {
// SAFETY: the surface is this object's own; the render loop released it from the
// thread before dropping.
unsafe {
eglDestroySurface(self.display, self.surface);
}
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Skia over the host's EGL context: one `DirectContext` for the host's lifetime, and a
//! `Surface` wrapping framebuffer 0 of whatever window surface is current, re-wrapped when
//! the window's size changes.
use super::egl::EglContext;
use anyhow::{anyhow, Result};
use skia_safe::gpu::{self, DirectContext, SurfaceOrigin};
use skia_safe::{ColorType, Surface};
/// GL_RGBA8 — the sized internal format of the RGBA8888 EGL config's default framebuffer.
const GL_RGBA8: u32 = 0x8058;
pub(super) struct Gpu {
pub(super) context: DirectContext,
}
impl Gpu {
/// The Skia context over the (already current) EGL context. `cache_bytes` is the resource
/// budget the console asked for (posters, glyph atlases — see `ConsoleOptions`).
pub(super) fn new(_egl: &EglContext, cache_bytes: usize) -> Result<Gpu> {
// Skia's native GL interface on Android assembles itself over `eglGetProcAddress`.
let interface = gpu::gl::Interface::new_native()
.ok_or_else(|| anyhow!("Skia: no native GL interface (is a GLES context current?)"))?;
let mut context = gpu::direct_contexts::make_gl(interface, None)
.ok_or_else(|| anyhow!("Skia: DirectContext over GLES failed"))?;
context.set_resource_cache_limit(cache_bytes);
log::info!(
"console: Skia GL DirectContext, {} MB resource budget",
cache_bytes >> 20
);
Ok(Gpu { context })
}
/// A Skia surface over the current window surface's default framebuffer.
pub(super) fn wrap_window(
&mut self,
egl: &EglContext,
width: u32,
height: u32,
) -> Result<Surface> {
let fb = gpu::gl::FramebufferInfo {
fboid: 0,
format: GL_RGBA8,
protected: gpu::Protected::No,
};
let samples = usize::try_from(egl.samples).unwrap_or(0);
let stencil = usize::try_from(egl.stencil_bits).unwrap_or(0);
let target = gpu::backend_render_targets::make_gl(
(width as i32, height as i32),
if samples > 1 { Some(samples) } else { None },
stencil,
fb,
);
gpu::surfaces::wrap_backend_render_target(
&mut self.context,
&target,
SurfaceOrigin::BottomLeft,
ColorType::RGBA8888,
None,
None,
)
.ok_or_else(|| anyhow!("Skia: wrap FBO 0 as a surface ({width}×{height})"))
}
}
+509
View File
@@ -0,0 +1,509 @@
//! The console host proper: one render thread that owns the EGL context, the Skia
//! `DirectContext` and the [`Console`], paced by `eglSwapBuffers` while a surface is up
//! and parked while there is none. Everything else — Kotlin's input, surface lifecycle,
//! session edges — arrives through a command queue and is applied on that thread; what the
//! console raises (actions, haptic pulses, editing state, settings to persist) leaves
//! through an event queue a Kotlin poll thread blocks on.
//!
//! The model side needs none of this: `ConsoleShared`/`LibraryShared` are lock-guarded and
//! written straight from JNI, `ConsoleBus` is drained straight from JNI. Only the shell
//! itself is single-threaded, and this thread is that thread.
use super::egl::{EglContext, EglSurface, GlesVersion};
use super::gpu::Gpu;
use anyhow::{bail, Result};
use ndk::native_window::NativeWindow;
use pf_client_core::console::{OverlayAction, PointerInput, SessionPhase};
use pf_client_core::menu_nav::{MenuEvent, MenuNav, MenuPulse, MenuSample, PadInfo};
use pf_console_ui::{
Console, ConsoleEntry, ConsoleHandles, ConsoleOptions, Insets, Key, SnapshotStore, Viewport,
};
use punktfunk_core::config::GamepadPref;
use std::collections::VecDeque;
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
/// A session edge as Kotlin reports it — `SessionPhase` borrows its strings, so the queue
/// carries an owned twin.
pub(super) enum Phase {
Connecting,
Streaming,
Failed(String),
Ended(Option<String>),
Reconnecting(String),
}
/// What Kotlin asks the render thread to do.
pub(super) enum Cmd {
Menu(MenuEvent),
/// The raw pad, whenever it changes; the thread feeds `MenuNav` with the LAST sample every
/// frame (repeats need a clock) and once on arrival (a press must not wait for a frame).
PadSample(MenuSample),
Pointer(PointerInput),
Key {
key: Key,
shift: bool,
repeat: bool,
},
Text(String),
Phase(Phase),
Navigate(ConsoleEntry),
SurfaceCreated(NativeWindow),
SurfaceChanged,
/// Acknowledged through `Shared::surface_gen` once the EGL surface is really gone —
/// Kotlin's `surfaceDestroyed` must not return before that.
SurfaceDestroyed,
Viewport {
insets: Insets,
scale: Option<f64>,
},
Pads {
label: Option<String>,
pref: Option<GamepadPref>,
pads: Vec<PadInfo>,
},
Quit,
}
/// What the render thread raises for Kotlin.
pub(super) enum HostEvent {
Action(OverlayAction),
Pulse(MenuPulse),
Editing(bool),
/// The shell saved settings: here is the whole snapshot to persist.
Settings(Box<pf_client_core::trust::Settings>),
/// The GLES generation the context came up with — Kotlin logs it, nothing more.
Gles(GlesVersion),
/// The render thread died (EGL/Skia init failed). Kotlin falls back to its own console.
Dead(String),
}
impl HostEvent {
/// The JSON Kotlin parses. Hand-rolled for the small variants; the two model payloads
/// ride serde.
pub(super) fn to_json(&self) -> String {
match self {
HostEvent::Action(a) => format!(
"{{\"action\":{}}}",
serde_json::to_string(a).unwrap_or_else(|_| "null".into())
),
HostEvent::Pulse(p) => format!(
"{{\"pulse\":\"{}\"}}",
match p {
MenuPulse::Move => "move",
MenuPulse::Confirm => "confirm",
MenuPulse::Boundary => "boundary",
}
),
HostEvent::Editing(e) => format!("{{\"editing\":{e}}}"),
HostEvent::Settings(s) => format!(
"{{\"settings\":{}}}",
serde_json::to_string(s).unwrap_or_else(|_| "null".into())
),
HostEvent::Gles(v) => format!(
"{{\"gles\":{}}}",
match v {
GlesVersion::Es2 => 2,
GlesVersion::Es3 => 3,
}
),
HostEvent::Dead(msg) => format!(
"{{\"dead\":{}}}",
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".into())
),
}
}
}
pub(super) struct Shared {
inbox: Mutex<VecDeque<Cmd>>,
inbox_cv: Condvar,
events: Mutex<VecDeque<HostEvent>>,
events_cv: Condvar,
/// Bumped by the render thread each time it has torn a surface down; `SurfaceDestroyed`
/// waits for the bump.
surface_gen: Mutex<u64>,
surface_cv: Condvar,
}
impl Shared {
fn new() -> Shared {
Shared {
inbox: Mutex::new(VecDeque::new()),
inbox_cv: Condvar::new(),
events: Mutex::new(VecDeque::new()),
events_cv: Condvar::new(),
surface_gen: Mutex::new(0),
surface_cv: Condvar::new(),
}
}
pub(super) fn send(&self, cmd: Cmd) {
self.inbox
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push_back(cmd);
self.inbox_cv.notify_one();
}
fn emit(&self, ev: HostEvent) {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push_back(ev);
self.events_cv.notify_one();
}
/// Kotlin's poll: the next event, waiting up to `timeout` for one.
pub(super) fn next_event(&self, timeout: Duration) -> Option<HostEvent> {
let mut q = self
.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if q.is_empty() {
let (guard, _) = self
.events_cv
.wait_timeout(q, timeout)
.unwrap_or_else(std::sync::PoisonError::into_inner);
q = guard;
}
q.pop_front()
}
/// Ask for the surface to go and wait (bounded) until it has.
pub(super) fn destroy_surface_blocking(&self) {
let before = *self
.surface_gen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.send(Cmd::SurfaceDestroyed);
let g = self
.surface_gen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// Bounded: a render thread that died mid-frame must not hang the UI thread forever —
// by then the EGL surface is gone with it anyway.
let _ = self
.surface_cv
.wait_timeout_while(g, Duration::from_secs(2), |g| *g == before)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
fn ack_surface_gone(&self) {
*self
.surface_gen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) += 1;
self.surface_cv.notify_all();
}
}
/// The host as the JNI layer holds it.
pub(super) struct ConsoleHost {
pub(super) shared: Arc<Shared>,
pub(super) handles: ConsoleHandles,
pub(super) store: Arc<SnapshotStore>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl ConsoleHost {
/// Start the render thread; it builds the console (shell + fonts — Skia handles, so it
/// cannot be built here and sent) and parks until a surface arrives. A build failure
/// arrives as a `Dead` event.
pub(super) fn start(
opts: ConsoleOptions,
entry: ConsoleEntry,
store: Arc<SnapshotStore>,
) -> ConsoleHost {
let shared = Arc::new(Shared::new());
let handles = ConsoleHandles::new();
let thread_shared = shared.clone();
let thread_store = store.clone();
let thread_handles = handles.clone();
let thread = std::thread::Builder::new()
.name("pf-console".into())
.spawn(move || {
let run = || -> Result<()> {
let console = Console::new(opts, entry, &thread_handles)?;
render_loop(console, thread_shared.clone(), thread_store)
};
if let Err(e) = run() {
log::error!("console: render thread ended: {e:#}");
thread_shared.emit(HostEvent::Dead(format!("{e:#}")));
}
})
.ok();
ConsoleHost {
shared,
handles,
store,
thread,
}
}
pub(super) fn stop(mut self) {
self.shared.send(Cmd::Quit);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// No input for this long = the console is being looked at, not used — halve the redraw
/// rate (`IDLE_FRAME_STEP` slept between swaps). 60 s keeps every interaction and its
/// afterglow at full smoothness and only calms a genuinely parked screen.
const IDLE_AFTER: Duration = Duration::from_secs(60);
/// One extra ~vsync period per frame while idle: 60 Hz → ~30, 120 Hz → ~40.
const IDLE_FRAME_STEP: Duration = Duration::from_millis(16);
/// The render thread. Owns EGL + Skia + the console; runs until `Cmd::Quit`.
fn render_loop(mut console: Console, shared: Arc<Shared>, store: Arc<SnapshotStore>) -> Result<()> {
let egl = EglContext::new()?;
shared.emit(HostEvent::Gles(egl.version));
let mut gpu: Option<Gpu> = None;
let mut window: Option<NativeWindow> = None;
let mut surface: Option<EglSurface> = None;
let mut skia: Option<(skia_safe::Surface, u32, u32)> = None;
let mut nav = MenuNav::new();
let mut sample = MenuSample::default();
let mut insets = Insets::default();
let mut scale: Option<f64> = None;
let mut pad_label: Option<String> = None;
let mut pad_pref: Option<GamepadPref> = None;
let mut pads: Vec<PadInfo> = Vec::new();
let mut was_editing = console.editing();
let mut saved_gen = store.saved_gen();
let mut menu_out: Vec<MenuEvent> = Vec::new();
// When the last input arrived — the idle throttle's clock (see the draw site below).
let mut last_input = Instant::now();
// Consecutive GL setup failures (window surface / Skia wrap). One is a transient (a window
// torn down mid-create); a run of them is a context that is not coming back — most likely
// reclaimed by Android while the app was backgrounded. Only exiting reports that: each
// failure alone is logged, the loop retries, and the screen stays a gray never-painted
// SurfaceView forever. Dying raises `Dead`, and Kotlin answers with the touch UI.
let mut gl_failures = 0u32;
const GL_FAILURE_LIMIT: u32 = 3;
loop {
// Take everything queued. With no surface up, block until something arrives.
let cmds: Vec<Cmd> = {
let mut q = shared
.inbox
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if surface.is_none() && q.is_empty() {
let (guard, _) = shared
.inbox_cv
.wait_timeout(q, Duration::from_millis(500))
.unwrap_or_else(std::sync::PoisonError::into_inner);
q = guard;
}
q.drain(..).collect()
};
let mut poll_now = false;
for cmd in cmds {
match cmd {
Cmd::Quit => {
// Release in order: the Skia surface, then the current binding, then (on
// return) the EGL surface + window + context.
drop(skia.take());
if surface.is_some() {
egl.release_current();
}
return Ok(());
}
Cmd::Menu(ev) => {
last_input = Instant::now();
if let Some(p) = console.menu(ev) {
shared.emit(HostEvent::Pulse(p));
}
}
Cmd::PadSample(s) => {
last_input = Instant::now();
sample = s;
poll_now = true;
}
Cmd::Pointer(p) => {
last_input = Instant::now();
console.pointer(p);
}
Cmd::Key { key, shift, repeat } => {
last_input = Instant::now();
console.key(key, shift, repeat);
}
Cmd::Text(t) => {
last_input = Instant::now();
console.text(&t);
}
Cmd::Phase(ph) => {
match &ph {
Phase::Connecting => console.session_phase(SessionPhase::Connecting),
Phase::Streaming => console.session_phase(SessionPhase::Streaming),
Phase::Failed(m) => console.session_phase(SessionPhase::Failed(m)),
Phase::Ended(r) => {
console.session_phase(SessionPhase::Ended(r.as_deref()));
}
Phase::Reconnecting(m) => {
console.session_phase(SessionPhase::Reconnecting(m));
}
}
// Coming back from a stream: whatever is held on the pad now (the chord
// that ended it) must be released before it can act here.
if matches!(ph, Phase::Ended(_) | Phase::Failed(_)) {
nav.reset();
}
}
Cmd::Navigate(entry) => console.navigate(entry),
Cmd::SurfaceCreated(w) => {
// A surface arriving while one is up: replace it (Kotlin re-created the
// view without a destroy in between — treat as destroy + create).
if surface.is_some() {
skia = None;
egl.release_current();
surface = None;
window = None;
}
match egl.window_surface(w.ptr().as_ptr().cast()) {
Ok(s) => {
if gpu.is_none() {
gpu = Some(Gpu::new(&egl, console.gpu_cache_bytes())?);
}
surface = Some(s);
window = Some(w);
gl_failures = 0;
// A fresh surface is a fresh entry: snapshot the pad so a button
// still held from before does not fire into the first frame.
nav.reset();
}
Err(e) => {
log::error!("console: window surface: {e:#}");
gl_failures += 1;
}
}
}
Cmd::SurfaceChanged => {
if let Some(s) = surface.as_mut() {
s.refresh_size();
}
}
Cmd::SurfaceDestroyed => {
skia = None;
if surface.is_some() {
egl.release_current();
}
surface = None;
window = None;
shared.ack_surface_gone();
}
Cmd::Viewport {
insets: i,
scale: s,
} => {
insets = i;
scale = s;
}
Cmd::Pads {
label,
pref,
pads: p,
} => {
pad_label = label;
pad_pref = pref;
pads = p;
}
}
}
// `window` is only held so the ANativeWindow outlives the EGL surface over it.
let _ = &window;
// The pad, through the shared synthesizer: once per frame for repeats, plus once
// right now if a sample just arrived.
if poll_now || surface.is_some() {
menu_out.clear();
nav.poll(&sample, Instant::now(), &mut menu_out);
for ev in menu_out.drain(..) {
if let Some(p) = console.menu(ev) {
shared.emit(HostEvent::Pulse(p));
}
}
}
// Draw, if there is somewhere to draw.
// ponytail: half-rate after 60 s without input — one extra frame period between
// swaps, so an idle carousel stops redrawing a phone's panel at its full rate
// (the aurora still breathes, at half tempo). Any input restores full rate on
// its own frame; damage-driven rendering if a TV box ever needs more.
if last_input.elapsed() >= IDLE_AFTER {
std::thread::sleep(IDLE_FRAME_STEP);
}
if let (Some(s), Some(g)) = (surface.as_mut(), gpu.as_mut()) {
let (w, h) = (s.width, s.height);
let need_wrap = match &skia {
Some((_, sw, sh)) => *sw != w || *sh != h,
None => true,
};
if need_wrap {
skia = None;
match g.wrap_window(&egl, w, h) {
Ok(surf) => {
skia = Some((surf, w, h));
gl_failures = 0;
}
Err(e) => {
log::error!("console: {e:#}");
gl_failures += 1;
}
}
}
if let Some((surf, _, _)) = skia.as_mut() {
let viewport = Viewport {
width: w,
height: h,
insets,
scale,
};
console.frame(
surf.canvas(),
&viewport,
pad_label.as_deref(),
pad_pref,
&pads,
);
g.context.flush_and_submit();
if let Err(e) = s.swap() {
// The window went away under us; wait for the next surface.
log::warn!("console: {e:#} — dropping the surface");
skia = None;
egl.release_current();
surface = None;
window = None;
}
}
}
if gl_failures >= GL_FAILURE_LIMIT {
// Same release order as `Cmd::Quit`: the Skia surface, the current binding, then (on
// return) the EGL surface + window + context drop.
drop(skia.take());
if surface.is_some() {
egl.release_current();
}
bail!("GL surface failed {gl_failures} times in a row — giving the screen back");
}
// Publish what the console raised.
while let Some(a) = console.take_action() {
shared.emit(HostEvent::Action(a));
}
let editing = console.editing();
if editing != was_editing {
was_editing = editing;
shared.emit(HostEvent::Editing(editing));
}
if store.saved_gen() != saved_gen {
let (settings, current_gen) = store.snapshot();
saved_gen = current_gen;
shared.emit(HostEvent::Settings(Box::new(settings)));
}
}
}
+802
View File
@@ -0,0 +1,802 @@
//! The Skia console UI on Android (design/android-skia-console-port.md, WP3): the same
//! `pf-console-ui` shell the Linux/Windows session binary shows, drawn by this crate onto
//! the `SurfaceView` Kotlin hands over, through Skia's GL backend on an EGL context this
//! module owns. Kotlin keeps the services (trust store, settings, discovery, the mTLS
//! library fetch, WoL, pairing) and feeds the console's models over these JNI seams; the
//! console's own asks — start a session, quit, copy text — come back as events.
//!
//! The seam in one breath: `nativeConsoleCreate` builds the console (a `jlong` handle),
//! the surface-lifecycle calls hand it somewhere to draw, `nativeConsoleSet*` /
//! `nativeConsoleLibrary*` push model snapshots (JSON, the model types' own serde shape),
//! `nativeConsoleMenu`/`PadSample`/`Pointer`/`Key`/`Text` are input,
//! `nativeConsoleNextEvent` is the blocking event poll (actions, haptic pulses, editing
//! state, settings to persist), `nativeConsoleDrainCmds` the command bus.
//!
//! JSON everywhere a struct crosses: `HostRow`, `PairPhase`, `WakeStatus`, `LibraryGame`,
//! `LibraryPhase`, `ConsoleCmd`, `OverlayAction`, `Settings`, `KnownHosts` all serialize with
//! serde in their defining crates — there is no second Android-side mirror to drift.
mod egl;
mod gpu;
mod host;
use host::{Cmd, ConsoleHost, Phase};
use jni::errors::LogErrorAndDefault;
use jni::objects::{JByteArray, JObject, JString};
use jni::sys::{jboolean, jfloat, jint, jlong};
use jni::EnvUnowned;
use pf_client_core::console::{PointerButton, PointerInput};
use pf_client_core::menu_nav::{MenuDir, MenuEvent, MenuSample, PadBattery, PadInfo};
use pf_console_ui::{
ConsoleEntry, ConsoleOptions, HostRow, Insets, Key, LibraryGame, LibraryPhase, PairPhase,
Platform, SnapshotStore, Stale, WakeStatus,
};
use punktfunk_core::config::GamepadPref;
use std::sync::Arc;
use std::time::Duration;
/// How long `nativeConsoleNextEvent` blocks at most — short enough that Kotlin's poll thread
/// notices `running = false` promptly on teardown (the rumble poll's cadence).
const EVENT_TIMEOUT: Duration = Duration::from_millis(100);
/// What Kotlin hands `nativeConsoleCreate`.
#[derive(serde::Deserialize)]
struct CreateOptions {
device_name: String,
/// Skia's resource budget, bytes (Kotlin sizes it from `ActivityManager.memoryClass`).
gpu_cache_bytes: usize,
/// Whether the touch shell exists as a fallback (phones/tablets; false on a TV) —
/// gates the console-off settings row. Default false: absent means don't offer it.
#[serde(default)]
fallback_ui: bool,
/// The settings snapshot the shell starts from (`pf_client_core::trust::Settings` JSON).
settings: pf_client_core::trust::Settings,
/// The profile catalog as `[[id, name], …]`.
#[serde(default)]
profiles: Vec<(String, String)>,
/// The known-hosts records (`KnownHosts` JSON) — for building `punktfunk://` links.
#[serde(default)]
known_hosts: pf_client_core::trust::KnownHosts,
/// Where to start: `{"home": true}` or `{"library": <HostRow>}`.
#[serde(default)]
entry: EntryJson,
}
#[derive(serde::Deserialize, Default)]
struct EntryJson {
#[serde(default)]
library: Option<HostRow>,
}
impl EntryJson {
fn into_entry(self) -> ConsoleEntry {
match self.library {
Some(h) => ConsoleEntry::Library(Box::new(h)),
None => ConsoleEntry::Home,
}
}
}
/// One controller as Kotlin describes it — `PadInfo` with the pref as its wire byte.
#[derive(serde::Deserialize)]
struct PadJson {
name: String,
key: String,
pref: u8,
#[serde(default)]
steam_virtual: bool,
#[serde(default)]
battery: Option<BatteryJson>,
/// `VID:PID · gamepad · dpad` — what the controllers screen prints under the name.
#[serde(default)]
detail: String,
#[serde(default)]
forwarded: bool,
#[serde(default)]
rumble: bool,
}
#[derive(serde::Deserialize)]
struct BatteryJson {
percent: u8,
charging: bool,
}
#[derive(serde::Deserialize)]
struct PadsJson {
#[serde(default)]
label: Option<String>,
/// The glyph style's pref as its wire byte; absent = keyboard glyphs.
#[serde(default)]
pref: Option<u8>,
#[serde(default)]
pads: Vec<PadJson>,
}
/// The `jlong` handle → the host. Every entry point takes the handle Kotlin got from
/// `nativeConsoleCreate` and returns it to `nativeConsoleDestroy` exactly once, never
/// concurrently with the destroy (Kotlin owns that ordering on its main thread; the event
/// poll thread is stopped and joined before destroy — same contract as the rumble poll).
fn host(handle: jlong) -> Option<&'static ConsoleHost> {
if handle == 0 {
return None;
}
// SAFETY: live handle per the create/destroy contract above.
Some(unsafe { &*(handle as *const ConsoleHost) })
}
fn json_arg<T: serde::de::DeserializeOwned>(env: &mut jni::Env, s: &JString) -> Option<T> {
let text = s.try_to_string(env).ok()?;
match serde_json::from_str::<T>(&text) {
Ok(v) => Some(v),
Err(e) => {
log::error!("console: bad JSON from Kotlin: {e} in {text:.200}");
None
}
}
}
/// `NativeBridge.nativeConsoleCreate(optionsJson): Long` — build the console (shell + fonts on
/// the caller's thread, then its render thread parked until a surface arrives). `0` on failure
/// (logged); Kotlin then keeps its own console.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleCreate(
mut env: EnvUnowned,
_this: JObject,
options: JString,
) -> jlong {
env.with_env(|env| -> jni::errors::Result<jlong> {
let Some(opts) = json_arg::<CreateOptions>(env, &options) else {
return Ok(0);
};
let store = Arc::new(SnapshotStore::new(opts.settings, opts.profiles));
store.set_known_hosts(opts.known_hosts);
let console_opts = ConsoleOptions {
device_name: opts.device_name,
deck: false,
fallback_ui: opts.fallback_ui,
store: Some(store.clone()),
platform: Platform::Android,
gpu_cache_bytes: opts.gpu_cache_bytes.max(16 << 20),
};
let host = ConsoleHost::start(console_opts, opts.entry.into_entry(), store);
Ok(Box::into_raw(Box::new(host)) as jlong)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleDestroy(handle)` — stop the render thread (joined) and free.
///
/// # Safety contract
/// `handle` must be `0` or a live handle, destroyed once, after the event poll thread stopped.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleDestroy(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the contract; ownership returns here exactly once.
let host = unsafe { Box::from_raw(handle as *mut ConsoleHost) };
host.stop();
}
/// `NativeBridge.nativeConsoleSurfaceCreated(handle, surface)` — the `SurfaceView`'s surface is
/// up; the render thread wraps it in EGL and starts drawing.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSurfaceCreated(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
surface: JObject,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let Some(h) = host(handle) else {
return Ok(());
};
// SAFETY: `env`/`surface` are valid JNI pointers for this call; the raw casts bridge the
// jni-sys version skew between the `jni` and vendored `ndk` crates (see nativeStartVideo).
let window = unsafe {
ndk::native_window::NativeWindow::from_surface(
env.get_raw() as *mut _,
surface.as_raw() as *mut _,
)
};
match window {
Some(w) => h.shared.send(Cmd::SurfaceCreated(w)),
None => log::error!("console: no ANativeWindow from Surface"),
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleSurfaceChanged(handle)` — size changed; the thread re-reads it.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSurfaceChanged(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
if let Some(h) = host(handle) {
h.shared.send(Cmd::SurfaceChanged);
}
}
/// `NativeBridge.nativeConsoleSurfaceDestroyed(handle)` — BLOCKS until the render thread has
/// released the EGL surface: Android forbids touching a `Surface` after `surfaceDestroyed`
/// returns, and the GL driver would otherwise still be presenting into it.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSurfaceDestroyed(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
if let Some(h) = host(handle) {
h.shared.destroy_surface_blocking();
}
}
/// `NativeBridge.nativeConsoleSetViewport(handle, left, top, right, bottom, scale)` — safe-area
/// insets in surface pixels and the design-unit scale (`0` = the shell's own couch formula).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetViewport(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
left: jfloat,
top: jfloat,
right: jfloat,
bottom: jfloat,
scale: jfloat,
) {
if let Some(h) = host(handle) {
h.shared.send(Cmd::Viewport {
insets: Insets {
left: left.max(0.0),
top: top.max(0.0),
right: right.max(0.0),
bottom: bottom.max(0.0),
},
scale: (scale > 0.0).then_some(f64::from(scale)),
});
}
}
/// `NativeBridge.nativeConsolePadSample(handle, buttons, lx, ly, dpad)` — the raw pad, whenever
/// it changes: `buttons` bit i = a, b, x, y, l1, r1 held; `lx`/`ly` the left stick in wire
/// units (±32767, +y = down); `dpad` bit i = up, down, left, right held. The shared
/// `MenuNav` turns it into menu events with the same dead zone, repeat cadence and hysteresis
/// as the desktop.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePadSample(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
buttons: jint,
lx: jint,
ly: jint,
dpad: jint,
) {
if let Some(h) = host(handle) {
let bit = |v: jint, i: u32| v & (1 << i) != 0;
h.shared.send(Cmd::PadSample(MenuSample {
buttons: [
bit(buttons, 0),
bit(buttons, 1),
bit(buttons, 2),
bit(buttons, 3),
bit(buttons, 4),
bit(buttons, 5),
],
lx: lx.clamp(-32767, 32767) as i16,
ly: ly.clamp(-32767, 32767) as i16,
dpad: [bit(dpad, 0), bit(dpad, 1), bit(dpad, 2), bit(dpad, 3)],
}));
}
}
/// `NativeBridge.nativeConsoleMenu(handle, event)` — a discrete menu event, for input that is
/// already an event on the Kotlin side (a TV remote's D-pad `KeyEvent`s, the touch escape hatch):
/// 0..3 = move up/down/left/right, 4 confirm, 5 back, 6 secondary (Y), 7 tertiary (X),
/// 8 jump back (L1), 9 jump forward (R1).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleMenu(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
event: jint,
) {
let ev = match event {
0 => MenuEvent::Move(MenuDir::Up),
1 => MenuEvent::Move(MenuDir::Down),
2 => MenuEvent::Move(MenuDir::Left),
3 => MenuEvent::Move(MenuDir::Right),
4 => MenuEvent::Confirm,
5 => MenuEvent::Back,
6 => MenuEvent::Secondary,
7 => MenuEvent::Tertiary,
8 => MenuEvent::JumpBack,
9 => MenuEvent::JumpForward,
_ => return,
};
if let Some(h) = host(handle) {
h.shared.send(Cmd::Menu(ev));
}
}
/// `NativeBridge.nativeConsolePointer(handle, kind, x, y, dy)` — touch/mouse in surface pixels:
/// kind 0 move, 1 primary down (a mouse — acts immediately), 2 primary up, 3 secondary down
/// (= Back), 4 wheel (`dy` steps, + = up), 5 cancel, 6 primary down from a finger/stylus on
/// the glass — the shell defers it so a swipe scrolls instead of acting on contact.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsolePointer(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
kind: jint,
x: jfloat,
y: jfloat,
dy: jfloat,
) {
let input = match kind {
0 => PointerInput::Move { x, y },
1 => PointerInput::Down {
x,
y,
button: PointerButton::Primary,
touch: false,
},
2 => PointerInput::Up {
x,
y,
button: PointerButton::Primary,
},
3 => PointerInput::Down {
x,
y,
button: PointerButton::Secondary,
touch: false,
},
4 => PointerInput::Wheel { x, y, dy },
5 => PointerInput::Cancel,
6 => PointerInput::Down {
x,
y,
button: PointerButton::Primary,
touch: true,
},
_ => return,
};
if let Some(h) = host(handle) {
h.shared.send(Cmd::Pointer(input));
}
}
/// `NativeBridge.nativeConsoleKey(handle, key, shift, repeat)` — a hardware key the console
/// understands: 0..3 left/right/up/down, 4 return, 5 space, 6 escape, 7 backspace, 8 page up,
/// 9 page down, 10 tab, 11 the letter Y, 12 the letter X. Anything else is Kotlin's to keep.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleKey(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
key: jint,
shift: jboolean,
repeat: jboolean,
) {
let key = match key {
0 => Key::Left,
1 => Key::Right,
2 => Key::Up,
3 => Key::Down,
4 => Key::Return,
5 => Key::Space,
6 => Key::Escape,
7 => Key::Backspace,
8 => Key::PageUp,
9 => Key::PageDown,
10 => Key::Tab,
11 => Key::Y,
12 => Key::X,
_ => return,
};
if let Some(h) = host(handle) {
h.shared.send(Cmd::Key { key, shift, repeat });
}
}
/// `NativeBridge.nativeConsoleText(handle, text)` — typed characters while the console reports
/// `editing` (see the `editing` event).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleText(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
text: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Ok(t)) = (host(handle), text.try_to_string(env)) {
h.shared.send(Cmd::Text(t));
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleSessionPhase(handle, phase, message)` — where the session the
/// console asked for stands: 0 connecting, 1 streaming, 2 failed(message), 3 ended(message or
/// empty = clean), 4 reconnecting(message).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSessionPhase(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
phase: jint,
message: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let Some(h) = host(handle) else {
return Ok(());
};
let msg = message.try_to_string(env).unwrap_or_default();
let ph = match phase {
0 => Phase::Connecting,
1 => Phase::Streaming,
2 => Phase::Failed(msg),
3 => Phase::Ended((!msg.is_empty()).then_some(msg)),
4 => Phase::Reconnecting(msg),
_ => return Ok(()),
};
h.shared.send(Cmd::Phase(ph));
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleNavigate(handle, entryJson)` — re-root the console (`{"library":
/// <HostRow>}` opens that host's shelf over Home; `{}` is Home).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNavigate(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
entry: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(e)) = (host(handle), json_arg::<EntryJson>(env, &entry)) {
h.shared.send(Cmd::Navigate(e.into_entry()));
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleSetPads(handle, padsJson)` — the connected controllers for the
/// chip, the settings rows and the controllers screen: `{"label": "DualSense", "pref": 1,
/// "pads": [{name, key, pref, steam_virtual, battery: {percent, charging} | null, detail,
/// forwarded, rumble}]}`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPads(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
pads: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let (Some(h), Some(p)) = (host(handle), json_arg::<PadsJson>(env, &pads)) else {
return Ok(());
};
let pads = p
.pads
.into_iter()
.map(|j| PadInfo {
name: j.name,
key: j.key,
pref: GamepadPref::from_u8(j.pref),
steam_virtual: j.steam_virtual,
battery: j.battery.map(|b| PadBattery {
percent: b.percent.min(100),
charging: b.charging,
}),
detail: j.detail,
forwarded: j.forwarded,
rumble: j.rumble,
})
.collect();
h.shared.send(Cmd::Pads {
label: p.label,
pref: p.pref.map(GamepadPref::from_u8),
pads,
});
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleNextEvent(handle): String` — block up to ~100 ms for the next
/// event: `{"action": <OverlayAction>}`, `{"pulse": "move"|"confirm"|"boundary"}`,
/// `{"editing": bool}`, `{"settings": <Settings>}` (persist it), `{"gles": 2|3}`,
/// `{"dead": "<why>"}`. Empty string on timeout / no handle. Run from a Kotlin poll thread.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNextEvent<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let out = match host(handle).and_then(|h| h.shared.next_event(EVENT_TIMEOUT)) {
Some(ev) => ev.to_json(),
None => String::new(),
};
env.new_string(out)
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleDrainCmds(handle): String` — every `ConsoleCmd` queued since the
/// last call, as a JSON array (`[]` when none). Poll on a short cadence from the service side.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleDrainCmds<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
handle: jlong,
) -> JString<'local> {
env.with_env(|env| -> jni::errors::Result<JString<'local>> {
let out = match host(handle) {
Some(h) => {
let cmds = h.handles.bus.drain();
serde_json::to_string(&cmds).unwrap_or_else(|_| "[]".into())
}
None => "[]".into(),
};
env.new_string(out)
})
.resolve::<LogErrorAndDefault>()
}
// ---- model pushers -----------------------------------------------------------------------
/// `NativeBridge.nativeConsoleSetHosts(handle, json)` — the home carousel's rows (`[HostRow]`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetHosts(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(rows)) = (host(handle), json_arg::<Vec<HostRow>>(env, &json)) {
h.handles.console.set_hosts(rows);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleSetPair(handle, json)` — the pairing ceremony's phase
/// (`"Idle"`, `"Busy"`, `{"Failed": "why"}`, `{"Paired": {"key": "…"}}`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetPair(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(p)) = (host(handle), json_arg::<PairPhase>(env, &json)) {
h.handles.console.set_pair(p);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleSetWake(handle, json)` — the wake-and-wait card's status
/// (`WakeStatus` JSON, or `null` to clear).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetWake(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(w)) = (host(handle), json_arg::<Option<WakeStatus>>(env, &json)) {
h.handles.console.set_wake(w);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleNotice(handle, text)` — a one-shot toast from a service worker.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleNotice(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
text: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Ok(t)) = (host(handle), text.try_to_string(env)) {
h.handles.console.set_notice(t);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleLibraryBegin(handle)` — a fetch is starting for the shelf on
/// screen: bumps the fetch epoch and sets `Loading`. Call this — not a bare `Loading` phase —
/// so the shelf can tell its own result from a previous host's cached one.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryBegin(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
) {
if let Some(h) = host(handle) {
h.handles.library.begin_fetch();
}
}
/// `NativeBridge.nativeConsoleLibraryPhase(handle, json)` — `"Loading"`, `"Empty"`, `"Ready"`,
/// or `{"Error": {"title", "body", "can_retry"}}`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryPhase(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(p)) = (host(handle), json_arg::<LibraryPhase>(env, &json)) {
h.handles.library.set_phase(p);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleLibraryGames(handle, json, cached)` — the catalog (`[LibraryGame]`);
/// `cached` = this is the last-known list from the cache, shown while the fetch runs.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryGames(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
cached: jboolean,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(games)) = (host(handle), json_arg::<Vec<LibraryGame>>(env, &json)) {
if cached {
h.handles.library.set_games_cached(games);
} else {
h.handles.library.set_games(games);
}
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleLibraryArt(handle, id, bytes)` — one title's poster, encoded
/// (JPEG/PNG); the shell decodes at the size it draws.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryArt(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
id: JString,
bytes: JByteArray,
) {
env.with_env(|env| -> jni::errors::Result<()> {
let Some(h) = host(handle) else {
return Ok(());
};
let id = id.try_to_string(env)?;
let bytes = env.convert_byte_array(&bytes)?;
h.handles.library.push_art(id, bytes);
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleLibraryRunning(handle, json)` — the ids the host has up
/// (`["steam:570", …]`).
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryRunning(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(ids)) = (host(handle), json_arg::<Vec<String>>(env, &json)) {
let up: std::collections::HashSet<String> = ids.into_iter().collect();
h.handles.library.set_running(&up);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleLibraryStale(handle, stale)` — 0 fresh, 1 waking, 2 offline.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleLibraryStale(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
stale: jint,
) {
if let Some(h) = host(handle) {
h.handles.library.set_stale(match stale {
1 => Stale::Waking,
2 => Stale::Offline,
_ => Stale::No,
});
}
}
/// `NativeBridge.nativeConsoleSetSettings(handle, json)` — a settings change made elsewhere
/// (the touch UI, a deep link): the shell reads this on its next mutation. Not a save.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetSettings(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(s)) = (
host(handle),
json_arg::<pf_client_core::trust::Settings>(env, &json),
) {
h.store.set(s);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleSetProfiles(handle, json)` — the profile catalog `[[id, name]]`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetProfiles(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(p)) = (host(handle), json_arg::<Vec<(String, String)>>(env, &json)) {
h.store.set_profiles(p);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleSetKnownHosts(handle, json)` — the known-hosts records
/// (`KnownHosts` JSON) the console builds `punktfunk://` links from.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleSetKnownHosts(
mut env: EnvUnowned,
_this: JObject,
handle: jlong,
json: JString,
) {
env.with_env(|env| -> jni::errors::Result<()> {
if let (Some(h), Some(k)) = (
host(handle),
json_arg::<pf_client_core::trust::KnownHosts>(env, &json),
) {
h.store.set_known_hosts(k);
}
Ok(())
})
.resolve::<LogErrorAndDefault>()
}
@@ -0,0 +1,610 @@
//! The ASurfaceControl present backend (default): MediaCodec → `AImageReader` → `ASurfaceControl`
//! transactions, scheduled against the panel's real present clock.
//!
//! Where the SurfaceView presenter ([`super::presenter`]) predicts SurfaceFlinger's latch off a
//! choreographer grid that Android down-rates for a game uid, this backend gets the truth: every
//! applied transaction reports its real latch time and the previous buffer's release fence on
//! completion ([`super::surface_control::PresentComplete`]). Those two facts are the whole point —
//! the panel period is learned from real latch spacings (no down-rate lie), the glass budget is
//! bounded by real completions (no mispredicted reopen backpressuring the codec), and the latch
//! metric is always available (not the best-effort `OnFrameRendered` the SurfaceView path leans on).
//!
//! Both present intents ride the one actuator — a desired present time on the transaction:
//! * **latency** (default `present_priority`): newest-wins. Each pump drains the reader to the
//! newest image (`acquireLatestImageAsync` drops the rest back to the pool) and presents it at
//! the next real vsync. Minimal depth.
//! * **smooth**: a small FIFO drained on each frame's [`CadenceClock`] due time — the source's own
//! cadence, recovered from the wire pts, finally with a truthful present clock beneath it.
//!
//! Memory safety does NOT rest on the release fences: an `AImage` (and the `AHardwareBuffer` it
//! wraps) stays alive through SurfaceFlinger's own reference taken by `setBuffer`, so deleting our
//! handle early at worst reuses a buffer a touch soon (a visible tear), never a use-after-free. The
//! fences are the correctness of *timing*, not of memory — which is what lets this ship behind an
//! auto-fallback with the residual risk being visual, not a crash.
use ndk::hardware_buffer::HardwareBuffer;
use ndk::media::image_reader::{AcquireResult, Image, ImageFormat, ImageReader};
use ndk::media::media_codec::MediaCodec;
use ndk::native_window::NativeWindow;
use punktfunk_core::phase::{CadenceClock, CadenceTuning, PanelGrid};
use std::collections::VecDeque;
use std::os::fd::OwnedFd;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::time::Instant;
use super::async_loop::DecodeEvent;
use super::latency::now_realtime_ns;
use super::presenter::PresentPriority;
use super::surface_control::{Layer, PresentComplete};
use super::vsync::now_monotonic_ns;
/// Reader pool depth. Must cover the codec's own in-flight outputs + the presenter's held candidate
/// / FIFO + the buffers still latched on SurfaceFlinger awaiting their release fence. Eight is
/// generous for a one-in-flight-ish presenter and small enough that no device balks.
const READER_MAX_IMAGES: i32 = 8;
/// SurfaceFlinger latch lead: a present targeted closer than this to a vsync is treated as missed
/// and the next grid point is used. Starts at 0 (the P2e on-glass finding — SF latched with no lead
/// on the NP3) and only ever grows if a device proves it needs more; kept simple here (fixed 0)
/// because the real-latch feedback makes the aggressive gamble self-correcting: a miss just presents
/// one vsync later, the same cost the predicted path always paid.
const LATCH_MARGIN_NS: i64 = 0;
/// Fallback panel period while none has been learned yet — one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
/// One image acquired from the reader, held until it is presented (or dropped as a newest-wins
/// eviction). Carries the decode stamps paired by pts for the latency metrics.
struct Acquired {
image: Image,
buffer: HardwareBuffer,
fence: Option<OwnedFd>,
pts_us: u64,
/// `CLOCK_REALTIME` decode-output stamp (for the skew-corrected end-to-end).
decoded_real: i128,
/// The source's due time on the cadence grid (`CLOCK_MONOTONIC`), `None` under latency.
due_ns: Option<i64>,
}
/// One image applied to SurfaceFlinger, awaiting its completion (metrics) and its successor's
/// completion (the release fence that frees it back to the pool).
struct Presented {
seq: u64,
image: Image,
pts_us: u64,
decoded_real: i128,
/// `CLOCK_REALTIME` / `CLOCK_MONOTONIC` instants the transaction was applied — the latch metric
/// pairs the completion's monotonic latch against `release_mono`, and rebases it onto realtime
/// via `release_real` for the skew-corrected end-to-end.
release_real: i128,
release_mono: i64,
}
/// The ASurfaceControl present backend.
pub(super) struct AscBackend {
reader: ImageReader,
/// Cached reader window handed to `MediaCodec::configure` as the decoder's output surface.
reader_window: NativeWindow,
layer: Layer,
/// `None` under latency; the source-cadence loop under smooth.
cadence: Option<CadenceClock>,
/// FIFO capacity: 0 = newest-wins (latency); 1..=3 = the smoothing store depth.
fifo_capacity: usize,
/// The negotiated source frame interval — the cadence cushion ceiling.
frame_interval_ns: i64,
/// Transactions applied but not yet completed — the real glass budget.
inflight: u32,
/// The pipeline depth the budget allows (2 = double-buffer; a shade more under smooth).
inflight_cap: u32,
// -- held images --
/// Latency: the newest acquired image not yet presented. Smooth leaves this `None`.
candidate: Option<Acquired>,
/// Smooth: images held for their due time, oldest first.
fifo: VecDeque<Acquired>,
/// Images on SurfaceFlinger, oldest first, awaiting release.
presented: VecDeque<Presented>,
// -- present clock --
/// The panel period learned from real latch spacings — a READOUT for the pf.present line only.
/// It must NOT drive the present target: the target produces the latch, so learning the period
/// from the latch and then targeting it locks the panel to whatever it first latched.
panel: PanelGrid,
/// The honest panel period from the mode table (`panel_hz`) — what the smooth grid snaps to.
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
panel_seed_ns: i64,
last_latch_ns: i64,
/// `ADataSpace` for the transaction (BT709 for SDR — never untagged; see `color_dataspace`).
dataspace: i32,
/// Layer frame-rate vote (source Hz), applied once.
frame_rate: f32,
src_w: i32,
src_h: i32,
// -- bookkeeping --
next_seq: u64,
/// Decode stamps parked at `on_output`, keyed by the pts the codec echoes onto the buffer:
/// `(pts_us, decoded_real_ns, decoded_mono_ns)`.
stamps: VecDeque<(u64, i128, i64)>,
// -- 1 Hz pf.present window --
released: u64,
skipped: u64,
displays: u64,
forced: u64,
latch_us: Vec<u64>,
pace_us: Vec<u64>,
e2e_us: Vec<u64>,
last_flush: Instant,
}
impl AscBackend {
/// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then
/// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the
/// negotiated decode size; `surface_size` the LIVE view size the layer composites into;
/// `panel_hz` the mode-table panel rate (seeds the learner);
/// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate.
#[allow(clippy::too_many_arguments)]
pub(super) fn create(
window: &NativeWindow,
src_w: i32,
src_h: i32,
surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
panel_hz: i32,
dataspace: i32,
source_hz: u32,
priority: PresentPriority,
) -> Option<AscBackend> {
let layer = Layer::create(window, surface_size)?;
let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE
| ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY;
let reader = match ImageReader::new_with_usage(
src_w.max(1),
src_h.max(1),
ImageFormat::PRIVATE,
usage,
READER_MAX_IMAGES,
) {
Ok(r) => r,
Err(e) => {
log::warn!("asc: ImageReader init failed ({e:?}) — falling back to SurfaceView");
return None;
}
};
let reader_window = match reader.window() {
Ok(w) => w,
Err(e) => {
log::warn!("asc: ImageReader has no window ({e:?}) — falling back to SurfaceView");
return None;
}
};
let frame_interval_ns = match source_hz {
0 => FALLBACK_PERIOD_NS,
hz => 1_000_000_000 / i64::from(hz),
};
let (fifo_capacity, cadence, inflight_cap) = match priority {
PresentPriority::Latency => (0usize, None, 2u32),
PresentPriority::Smooth { buffer } => (
buffer,
Some(CadenceClock::new(CadenceTuning::snapping())),
(buffer as u32 + 1).clamp(2, 4),
),
};
log::info!(
"asc: backend up — {} ({}x{} @ {} Hz src, panel seed {} Hz, dataspace {:#x})",
match priority {
PresentPriority::Latency => "latency (newest-wins)".to_string(),
PresentPriority::Smooth { buffer } => format!("smooth (buffer {buffer})"),
},
src_w,
src_h,
source_hz,
panel_hz,
dataspace,
);
Some(AscBackend {
reader,
reader_window,
layer,
cadence,
fifo_capacity,
frame_interval_ns,
inflight: 0,
inflight_cap,
candidate: None,
fifo: VecDeque::new(),
presented: VecDeque::new(),
panel: PanelGrid::seeded(panel_hz),
panel_seed_ns: if panel_hz > 0 {
1_000_000_000 / panel_hz as i64
} else {
FALLBACK_PERIOD_NS
},
last_latch_ns: 0,
dataspace,
frame_rate: if source_hz > 0 { source_hz as f32 } else { 0.0 },
src_w: src_w.max(1),
src_h: src_h.max(1),
next_seq: 0,
stamps: VecDeque::new(),
released: 0,
skipped: 0,
displays: 0,
forced: 0,
latch_us: Vec::with_capacity(256),
pace_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
last_flush: Instant::now(),
})
}
/// The decoder output surface (the reader's window) for `MediaCodec::configure`.
pub(super) fn reader_window(&self) -> &NativeWindow {
&self.reader_window
}
/// Re-anchor the cadence loop on the next frame — the discontinuity hook the decode loop calls
/// when the re-anchor gate arms (a loss froze the picture and the decoder recovered behind it,
/// so the source→presentable delay the loop measured no longer holds). No-op under latency.
pub(super) fn reset_cadence(&mut self) {
if let Some(c) = self.cadence.as_mut() {
c.reset();
}
}
/// Route one decoded output buffer: render it into the reader when `present` (the re-anchor
/// gate approved it), else drop it off-glass. Parks the decode stamps for the pts the codec
/// echoes onto the buffer so `pump` can pair the latency metrics after acquire.
pub(super) fn on_output(
&mut self,
codec: &MediaCodec,
index: usize,
pts_us: u64,
decoded_real: i128,
decoded_mono: i64,
present: bool,
) {
if present {
self.stamps.push_back((pts_us, decoded_real, decoded_mono));
if self.stamps.len() > 128 {
self.stamps.pop_front();
}
}
if let Err(e) = codec.release_output_buffer_by_index(index, present) {
log::warn!("asc: release_output_buffer_by_index({index}, {present}): {e}");
}
}
/// Pop the decode stamps for `pts_us`, evicting older entries (decode order == input order).
fn take_stamp(&mut self, pts_us: u64) -> Option<(i128, i64)> {
while let Some(&(p, real, mono)) = self.stamps.front() {
if p > pts_us {
break;
}
self.stamps.pop_front();
if p == pts_us {
return Some((real, mono));
}
}
None
}
/// The desired present time for the frame being released, `CLOCK_MONOTONIC` (`0` = ASAP, only
/// used to bootstrap the phase before the first latch is known).
///
/// Both modes snap `not_before` up to an explicit panel-grid point: without one, applying two
/// transactions close together lets SurfaceFlinger coalesce the pair onto a single vsync and
/// idle the next — the on-glass 60-on-a-120-panel result of a plain ASAP present. Giving each
/// frame its own grid-spaced present time makes SF present them on consecutive vsyncs.
///
/// PERIOD is the mode-table seed (the honest panel maximum) — NEVER the latch-learned period,
/// or a slow latch would ratchet the target down and hold the panel at the lower rate. PHASE is
/// the last real latch. Latency passes `not_before = now + margin`; smooth additionally floors
/// it at the source due time.
fn next_present_target(&self, now_mono: i64, not_before: i64) -> i64 {
let period = self.panel_seed_ns;
if self.last_latch_ns <= 0 || period <= 0 {
return 0; // bootstrap: no phase yet — present ASAP to establish the first latch
}
let floor = not_before.max(now_mono);
let ahead = floor - self.last_latch_ns;
let k = ahead.div_euclid(period) + 1;
self.last_latch_ns + k.max(1) * period
}
/// Drain the reader into the held set (newest-wins candidate, or the smoothing FIFO), then
/// present the due frame if the budget is open. Returns `true` when a frame was applied.
pub(super) fn pump(
&mut self,
now_mono: i64,
stats: &crate::stats::VideoStats,
ev_tx: &mpsc::Sender<DecodeEvent>,
) -> bool {
self.drain_reader();
if self.inflight >= self.inflight_cap {
return false;
}
// Pick the frame to present.
let frame = if self.fifo_capacity == 0 {
self.candidate.take()
} else {
let reach = now_mono + LATCH_MARGIN_NS + self.panel_seed_ns;
match self.fifo.front() {
Some(f) if f.due_ns.is_none_or(|due| due <= reach) => self.fifo.pop_front(),
_ => return false,
}
};
let Some(mut frame) = frame else {
return false;
};
let not_before = frame.due_ns.map_or(now_mono + LATCH_MARGIN_NS, |d| {
d.max(now_mono + LATCH_MARGIN_NS)
});
let target = self.next_present_target(now_mono, not_before);
let seq = self.next_seq;
let applied = self.layer.present(
&frame.buffer,
self.src_w,
self.src_h,
frame.fence.take(),
target,
self.dataspace,
// The layer's fixed-source rate — applied once, at layer config (see `Layer::present`).
self.frame_rate,
seq,
ev_tx,
);
if !applied {
return false; // transaction failed; the image drops here, back to the pool
}
let release_real = now_realtime_ns();
let pace_us = ((release_real - frame.decoded_real).max(0) / 1000) as u64;
self.pace_us.push(pace_us);
stats.note_release(pace_us);
self.presented.push_back(Presented {
seq,
image: frame.image,
pts_us: frame.pts_us,
decoded_real: frame.decoded_real,
release_real,
release_mono: now_mono,
});
self.inflight += 1;
self.next_seq += 1;
self.released += 1;
true
}
/// Acquire newly rendered images out of the reader: latency keeps only the newest (older are
/// dropped back to the pool by `acquireLatest`); smooth keeps order up to capacity.
fn drain_reader(&mut self) {
if self.fifo_capacity == 0 {
// Newest-wins: one acquire-latest collapses the whole burst to the freshest buffer.
if let Some(acq) = self.acquire(true) {
if self.candidate.replace(acq).is_some() {
self.skipped += 1; // an un-presented candidate was superseded
}
}
} else {
// Smooth: pull every ready image in order into the FIFO, evicting the oldest past cap.
while let Some(acq) = self.acquire(false) {
self.fifo.push_back(acq);
while self.fifo.len() > self.fifo_capacity {
self.fifo.pop_front();
self.skipped += 1;
}
}
}
}
/// Acquire one image (`latest` drops older, else FIFO) and pair its decode stamps + cadence due.
/// `None` when the reader is empty or a transient acquire error occurs.
fn acquire(&mut self, latest: bool) -> Option<Acquired> {
// SAFETY: we never touch the image's pixels — the acquire fence is handed straight to
// SurfaceFlinger via `setBuffer`, which is exactly the "await before access" the async
// acquire requires.
let res = unsafe {
if latest {
self.reader.acquire_latest_image_async()
} else {
self.reader.acquire_next_image_async()
}
};
let (image, fence) = match res {
Ok(AcquireResult::Image(pair)) => pair,
Ok(_) => return None, // no buffer available / max acquired
Err(e) => {
log::warn!("asc: acquire image failed: {e:?}");
return None;
}
};
let buffer = match image.hardware_buffer() {
Ok(b) => b,
Err(e) => {
log::warn!("asc: image has no hardware buffer: {e:?}");
return None; // `image` drops here → back to the pool
}
};
// The buffer timestamp is the pts the codec echoed (ns); pair the parked decode stamps.
let pts_ns = image.timestamp().unwrap_or(0).max(0);
let pts_us = (pts_ns / 1000) as u64;
let (decoded_real, decoded_mono) = self
.take_stamp(pts_us)
.unwrap_or((now_realtime_ns(), now_monotonic_ns()));
let due_ns = self.cadence.as_mut().map(|c| {
c.due_ns(
pts_us.saturating_mul(1000),
decoded_mono,
self.frame_interval_ns,
)
});
Some(Acquired {
image,
buffer,
fence,
pts_us,
decoded_real,
due_ns,
})
}
/// A completed transaction: reopen the budget, learn the panel period from the real latch,
/// record the latch + end-to-end, and free the buffer this frame replaced with its release
/// fence. Runs on the decode thread (the callback only forwarded the data).
pub(super) fn on_present_complete(
&mut self,
pc: PresentComplete,
clock_offset: i64,
stats: &crate::stats::VideoStats,
video_e2e: &AtomicU64,
) {
self.inflight = self.inflight.saturating_sub(1);
// Metrics for the frame that just latched (its own `seq`).
if pc.latch_ns > 0 {
if let Some(p) = self.presented.iter().find(|p| p.seq == pc.seq) {
let latch_ns = (pc.latch_ns - p.release_mono).clamp(0, 10_000_000_000);
let displayed_real = p.release_real + latch_ns as i128;
let e2e_ns = displayed_real + clock_offset as i128 - p.pts_us as i128 * 1000;
let latch_use = (latch_ns / 1000) as u64;
let display_use = ((displayed_real - p.decoded_real).max(0) / 1000) as u64;
self.latch_us.push(latch_use);
self.displays += 1;
if e2e_ns > 0 && e2e_ns < 10_000_000_000 {
let e2e_use = (e2e_ns / 1000) as u64;
self.e2e_us.push(e2e_use);
// Publish glass-to-glass RAW for the audio plane to align against.
video_e2e.store(e2e_ns as u64, Ordering::Relaxed);
stats.note_displayed(Some(e2e_use), Some(display_use), Some(latch_use));
} else {
stats.note_displayed(None, Some(display_use), Some(latch_use));
}
}
// Learn the true panel period from consecutive real latches.
if self.last_latch_ns > 0 {
self.panel.observe(pc.latch_ns - self.last_latch_ns);
}
self.last_latch_ns = pc.latch_ns;
}
// Retire every buffer this transaction replaced (seq < completed): the immediate
// predecessor gets the real release fence, any older straggler a plain delete (memory-safe
// — SurfaceFlinger holds its own reference until it is actually done).
let mut retired: Vec<Presented> = Vec::new();
while self.presented.front().is_some_and(|p| p.seq < pc.seq) {
retired.push(self.presented.pop_front().unwrap());
}
match (retired.pop(), pc.prev_release_fence) {
(Some(last), Some(fence)) => last.image.delete_async(fence),
(Some(last), None) => drop(last.image),
(None, Some(fence)) => drop(fence),
(None, None) => {}
}
// (`retired` now holds only older stragglers, dropped here — plain AImage_delete.)
drop(retired);
}
/// Publish the reader-drop count to the HUD and emit the 1 Hz `pf.present` mirror line. Called
/// once per loop pass; the `skipped` counter feeds the HUD each pass, the log line at 1 Hz.
pub(super) fn flush(&mut self, stats: &crate::stats::VideoStats) {
if self.skipped > 0 {
stats.note_skipped(std::mem::take(&mut self.skipped));
}
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
return;
}
self.last_flush = Instant::now();
if self.released == 0 && self.displays == 0 {
return; // idle
}
let (latch_p50, latch_max) = p50_max_ms(std::mem::take(&mut self.latch_us));
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
let (e2e_p50, e2e_max) = p50_max_ms(std::mem::take(&mut self.e2e_us));
// Under the smoothness intent, tail the source-cadence loop's health: `late‰` of all frames
// folded (a due time already past when the frame became presentable — the direct signal the
// cushion is too small, WP8's acceptance criterion), `jitter` (the loop residual's mean
// absolute deviation), `cushion`, and `reanchors`. Absent under latency (no loop). Counters
// are cumulative since the last re-anchor, so `late` reads as a rate over enough frames.
let cadence = self
.cadence
.as_ref()
.map(CadenceClock::health)
.map(|h| {
format!(
" late={}‰ jitterMs={:.2} cushionMs={:.2} reanchors={}",
h.late.saturating_mul(1000) / h.frames.max(1),
h.jitter_ns as f64 / 1e6,
h.cushion_ns as f64 / 1e6,
h.reanchors,
)
})
.unwrap_or_default();
log::info!(
target: "pf.present",
"asc released={} displays={} inflight={} qDepth={} paceMs p50={:.2} max={:.2} \
latchMs p50={:.2} max={:.2} e2eMs p50={:.2} max={:.2} panelMs={:.2} forced={}{}",
self.released,
self.displays,
self.inflight,
self.fifo.len(),
pace_p50,
pace_max,
latch_p50,
latch_max,
e2e_p50,
e2e_max,
self.panel.period_ns() as f64 / 1e6,
self.forced,
cadence,
);
self.released = 0;
self.displays = 0;
}
/// Teardown: drop every held image (candidate, FIFO, and still-presented) back to the pool
/// before the reader + codec go away. Plain deletes — SurfaceFlinger releases its own refs as
/// it finishes, so this is memory-safe without waiting on the fences.
pub(super) fn release_all(&mut self) {
self.candidate = None;
self.fifo.clear();
self.presented.clear();
}
}
impl AscBackend {
/// Update the `ADataSpace` applied to every subsequent transaction (a refinement from the
/// codec's output format — the analogue of the SurfaceView path's `apply_hdr_dataspace`; the
/// negotiated colour set the initial value at create).
pub(super) fn set_dataspace(&mut self, dataspace: i32) {
if self.dataspace != dataspace {
self.dataspace = dataspace;
log::info!("asc: buffer dataspace now {dataspace:#x}");
}
}
}
/// Whether the ASurfaceControl backend is selected. Default ON; `debug.punktfunk.present_backend =
/// surfaceview` forces the legacy SurfaceView presenter (the field escape hatch, no rebuild). Any
/// other value — or an ASC init failure downstream — still lands on ASC-then-fallback.
pub(super) fn asc_backend_selected() -> bool {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.present_backend".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
!(n > 0 && &buf[..n as usize] == b"surfaceview")
}
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
if v.is_empty() {
return (0.0, 0.0);
}
v.sort_unstable();
(
v[v.len() / 2] as f64 / 1000.0,
*v.last().unwrap() as f64 / 1000.0,
)
}
+260 -60
View File
@@ -13,8 +13,10 @@ use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant};
use super::asc_presenter::{asc_backend_selected, AscBackend};
use super::display::{
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
apply_hdr_dataspace, color_dataspace, hdr_dataspace, install_render_callback,
release_render_callback, DisplayTracker,
};
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags, take_stamp};
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
@@ -22,6 +24,7 @@ use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::surface_control::PresentComplete;
use super::vsync::{now_monotonic_ns, VsyncClock};
use super::{
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
@@ -43,7 +46,7 @@ struct OutputReady {
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
pub(super) enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder. The `u32` is the
/// feeder's [`NativeClient::note_frame_index`] verdict — the forward frame-index gap's WIDTH
/// (0 = none), so the loop arms the freeze gate with the same signal and pre-credits the
@@ -63,6 +66,10 @@ enum DecodeEvent {
FormatChanged,
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
Vsync,
/// An `ASurfaceControl` transaction completed (ASurfaceControl backend only): the real latch
/// time + the previous buffer's release fence, forwarded from the completion callback (a binder
/// thread) so the decode loop applies it on its own thread.
PresentComplete(super::surface_control::PresentComplete),
/// The codec reported an error; `fatal` when neither recoverable nor transient.
Error { fatal: bool },
}
@@ -89,6 +96,7 @@ pub(super) fn run_async(
present_priority,
smooth_buffer,
panel_hz,
surface_size,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -176,7 +184,37 @@ pub(super) fn run_async(
}
}
}
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
// Resolve the present intent once (shared by both backends).
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
// The present backend. ASurfaceControl (default) drives its own `AImageReader` output surface +
// compositor layer, scheduling against the panel's real present clock; the SurfaceView presenter
// below is the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview`
// sysprop. A non-null `asc` means the codec renders into the reader, not the SurfaceView window.
let mut asc = if asc_backend_selected() {
// The negotiated colour is authoritative (PQ vs HLG, range) — not a guess the codec's
// output format later corrects; many decoders never echo `color-transfer` at all.
let initial_ds = color_dataspace(&client.color);
AscBackend::create(
&window,
mode.width as i32,
mode.height as i32,
surface_size,
panel_hz,
initial_ds,
mode.refresh_hz,
priority,
)
} else {
log::info!("decode: present backend = SurfaceView (present_backend sysprop)");
None
};
// The decoder's output surface: the reader's window when ASC is active, else the SurfaceView.
let configure_window: &NativeWindow = asc.as_ref().map_or(&window, |a| a.reader_window());
if let Err(e) = codec.configure(
&format,
Some(configure_window),
MediaCodecDirection::Decoder,
) {
log::error!("decode: configure failed: {e}");
return;
}
@@ -190,8 +228,10 @@ pub(super) fn run_async(
mode.height
);
// The forced TV mode switch (`is_tv` ⇒ ALWAYS strategy) is part of the experimental stack;
// off, every form factor gets the original soft seamless hint.
if mode.refresh_hz > 0
// off, every form factor gets the original soft seamless hint. ASC votes the rate on its own
// layer instead (the SurfaceView window shows nothing under the ASC path).
if asc.is_none()
&& mode.refresh_hz > 0
&& !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv && low_latency_mode)
{
log::debug!(
@@ -205,6 +245,11 @@ pub(super) fn run_async(
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
// HUD is visible.
let clock_offset = client.clock_offset_shared();
// The shared cell the audio plane steers its jitter ring by — video is the master, and the
// present path is the only point that knows when a frame actually reached glass. Both backends
// publish into it (the ASC path from its transaction completions, the SurfaceView path from the
// OnFrameRendered tracker).
let video_e2e = client.video_e2e_shared();
// Whether the adaptive-bitrate controller wants the `decode` stage as its decoder-backlog
// signal (Automatic, non-PyroWave): then `in_flight` is fed regardless of the HUD.
let measure_decode = client.wants_decode_latency();
@@ -212,27 +257,30 @@ pub(super) fn run_async(
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
// parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
// reclaimed after the codec is dropped below. SurfaceView backend only — the ASC path measures
// its display stage directly off the transaction completions.
let meter = Arc::new(PresentMeter::new());
// The tracker also publishes each confirmed present's end-to-end into the shared cell the audio
// plane steers its jitter ring by (`design/audio-latency-overhaul.md`) — video is the master,
// and this is the only point that knows when a frame actually reached glass.
let tracker = DisplayTracker::new(
stats.clone(),
clock_offset.clone(),
client.video_e2e_shared(),
video_e2e.clone(),
meter.clone(),
);
let render_cb = install_render_callback(&codec, &tracker);
let render_cb = if asc.is_none() {
install_render_callback(&codec, &tracker)
} else {
None
};
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
// legacy release-immediately path for a rebuild-free on-device A/B.
let mut presenter = if presenter_disabled_by_sysprop() {
// The SurfaceView timeline presenter (see `presenter.rs`): newest-wins / smoothing store,
// one-in-flight glass budget, timeline-timed release. `None` under the ASC backend, or when
// `debug.punktfunk.presenter = arrival` selects the legacy release-immediately path.
let mut presenter = if asc.is_some() {
None
} else if presenter_disabled_by_sysprop() {
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
None
} else {
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
log::info!(
"decode: presenter = timeline ({})",
match priority {
@@ -242,11 +290,15 @@ pub(super) fn run_async(
);
Some(Presenter::new(priority, mode.refresh_hz))
};
stats.set_presenter_active(presenter.is_some());
stats.set_presenter_active(presenter.is_some() || asc.is_some());
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
// the same event channel. The Sender parks here until that moment.
// the same event channel. The ASC backend derives its present clock from the real transaction
// latches instead, so it needs no choreographer.
let mut vsync: Option<VsyncClock> = None;
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
// A persistent Sender for the ASC path: the pump hands it to each transaction's completion
// callback, and it keeps the event channel alive for those callbacks.
let present_tx = asc.as_ref().map(|_| ev_tx.clone());
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
@@ -337,35 +389,52 @@ pub(super) fn run_async(
let mut fmt_dirty = false;
let mut vsync_tick = false;
let mut aus_dropped: u64 = 0;
// ASurfaceControl transaction completions coalesced into this pass, applied after the
// event drain (they run on the decode thread, not the binder thread that posted them).
let mut present_completes: Vec<PresentComplete> = Vec::new();
if let Some(ev) = ev0 {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
if let DecodeEvent::PresentComplete(pc) = ev {
present_completes.push(pc);
} else {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
}
// Coalesce every other event already queued into this one work pass — correct newest-only
// presentation across a decode burst, and batched feeding.
while let Ok(ev) = ev_rx.try_recv() {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
if let DecodeEvent::PresentComplete(pc) = ev {
present_completes.push(pc);
} else {
aus_dropped += u64::from(dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
}
if let Some(a) = asc.as_mut() {
let off = clock_offset.load(Ordering::Relaxed);
for pc in present_completes.drain(..) {
a.on_present_complete(pc, off, &stats, &video_e2e);
}
}
if vsync_tick {
if let Some(p) = presenter.as_mut() {
@@ -374,7 +443,17 @@ pub(super) fn run_async(
}
stats.note_skipped_overflow(aus_dropped); // parked-AU overflow: skips, flagged as such
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
if let Some(a) = asc.as_mut() {
// ASC carries the HDR signal on the transaction, not the SurfaceView window.
// Refine only when the codec actually reports an HDR transfer — a `None` echo
// (decoders commonly omit `color-transfer`) must not clobber the negotiated
// dataspace back to SDR before the first present.
if let Some(ds) = hdr_dataspace(&codec) {
a.set_dataspace(i32::from(ds));
}
} else {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
}
}
feed_ready(
&codec,
@@ -399,26 +478,48 @@ pub(super) fn run_async(
if let Some(p) = presenter.as_mut() {
p.reset_cadence();
}
if let Some(a) = asc.as_mut() {
a.reset_cadence();
}
}
let had_output = !ready.is_empty();
let rendered_before = rendered;
present_ready(
&codec,
&client,
measure_decode,
&mut ready,
&stats,
&in_flight,
&mut queued_stamps,
&meter,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut presenter,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
if let Some(a) = asc.as_mut() {
// ASC path: fold the gate + record the decode-stage split (same as the SurfaceView
// path's measurement half), then render each approved output into the reader; the pump
// below composites it onto the layer.
asc_present_ready(
a,
&codec,
&client,
measure_decode,
&mut ready,
&stats,
&in_flight,
&mut queued_stamps,
clock_offset.load(Ordering::Relaxed),
&mut gate,
&mut recovery_flags,
);
} else {
present_ready(
&codec,
&client,
measure_decode,
&mut ready,
&stats,
&in_flight,
&mut queued_stamps,
&meter,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut presenter,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
}
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
// even when the choreographer clock is absent.
@@ -475,6 +576,18 @@ pub(super) fn run_async(
}
}
}
// The ASurfaceControl backend's decision point — same "runs every pass" contract as the
// SurfaceView presenter, but its clock is the real transaction latches, so no choreographer
// is consulted. `present_tx` is the persistent Sender each transaction's completion callback
// rides back on.
if let Some(a) = asc.as_mut() {
if let Some(tx) = present_tx.as_ref() {
if a.pump(now_monotonic_ns(), &stats, tx) {
rendered += 1;
}
}
a.flush(&stats);
}
let presented_now = rendered > rendered_before;
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
@@ -584,6 +697,9 @@ pub(super) fn run_async(
if let Some(p) = presenter.as_mut() {
p.release_all(&codec); // hand every held output buffer back before the codec stops
}
if let Some(a) = asc.as_mut() {
a.release_all(); // drop every held image back to the reader pool before it goes away
}
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
@@ -591,6 +707,10 @@ pub(super) fn run_async(
let _ = j.join();
}
drop(codec); // AMediaCodec_delete — after this no render callback can fire
// The ASC layer + reader outlive the codec (which rendered into the reader's window); dropping
// now releases the reader and decrements the compositor control's refcount — the control itself
// is freed only once every in-flight completion callback has also dropped its share.
drop(asc);
if let Some(ud) = render_cb {
// SAFETY: the codec was dropped above; this registration's single reclaim.
unsafe { release_render_callback(ud) };
@@ -777,6 +897,9 @@ fn dispatch_event(
gate.arm(Instant::now());
}
}
// Intercepted by the caller before it ever reaches here (routed to the ASC backend on the
// decode thread); this arm keeps the match exhaustive.
DecodeEvent::PresentComplete(_) => {}
}
false
}
@@ -1059,3 +1182,80 @@ fn present_ready(
}
stats.note_skipped(skipped); // HUD `skipped` counter (newest-wins + held-off drops); no-op hidden
}
/// The ASurfaceControl backend's analogue of [`present_ready`]: record the same decode-stage split
/// (the HUD histogram + the ABR decoder-backlog signal), then fold each decoded output through the
/// re-anchor gate and render it into the reader (`present = true`) or drop it off-glass. The pump
/// composites the rendered images onto the layer; the display stage is measured there from the real
/// transaction latches, not here. `ready` is drained.
#[allow(clippy::too_many_arguments)] // one call site; mirrors `present_ready`'s measurement half
fn asc_present_ready(
asc: &mut AscBackend,
codec: &MediaCodec,
client: &NativeClient,
measure_decode: bool,
ready: &mut Vec<OutputReady>,
stats: &crate::stats::VideoStats,
in_flight: &Mutex<VecDeque<(u64, i128)>>,
queued_stamps: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
) {
if ready.is_empty() {
return;
}
// Decode-stage measurement (identical to the SurfaceView path's first block, minus the
// PresentMeter — the ASC backend keeps its own 1 Hz line). Pairs each output's receipt +
// queued stamps for the `decode` histogram, the feed/codec split, and the ABR signal.
{
let want_stage = stats.enabled() || measure_decode;
let mut g = in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() {
let received_ns = if want_stage {
note_decoded_pts(
client,
measure_decode,
stats,
&mut g,
clock_offset,
o.pts_us,
o.decoded_ns,
)
} else {
None
};
let queued = take_stamp(queued_stamps, o.pts_us);
let codec_us = queued.map(|q| ((o.decoded_ns - q).max(0) / 1000) as u64);
if let Some(c) = codec_us {
let feed_us = match (queued, received_ns) {
(Some(q), Some(r)) => Some(((q - r).max(0) / 1000) as u64),
_ => None,
};
stats.note_decode_split(feed_us, c);
}
}
}
// Fold every output through the gate in pts (== decode) order — a `false` verdict is withheld
// concealment (dropped off-glass, the ASC equivalent of the SurfaceView release-unrendered).
let now = Instant::now();
let mut withheld: u64 = 0;
for o in ready.drain(..) {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
if !present {
withheld += 1;
}
asc.on_output(
codec,
o.index,
o.pts_us,
o.decoded_ns,
o.decoded_mono_ns,
present,
);
}
stats.note_skipped(withheld); // gate-withheld frames (the reader-drop skips ride `asc.flush`)
}
@@ -274,3 +274,26 @@ pub(super) fn hdr_dataspace(codec: &MediaCodec) -> Option<DataSpace> {
_ => None, // SDR (BT.709 / SDR_VIDEO) or unspecified
}
}
/// Map the *negotiated* session colour ([`ColorInfo`], carried on Welcome) to the `ADataSpace`
/// the presenter should tag buffers with. This is the authoritative source — the wire contract
/// says clients configure the presenter from these code points, not from what the decoder happens
/// to echo back (many decoders omit `color-transfer` from the output format).
///
/// SDR maps to `BT709` (limited-range video), never `0`/untagged: an untagged buffer on an
/// ASurfaceControl transaction leaves SurfaceFlinger to guess, and a full-range guess shows
/// limited-range black (16) as gray — the elevated-blacks bug.
// ponytail: full-range SDR would need hand-composed dataspace bits (no named constant); the host
// only encodes limited-range SDR today (ColorInfo::SDR_BT709), so BT709 covers every SDR session.
pub(super) fn color_dataspace(color: &punktfunk_core::quic::ColorInfo) -> i32 {
use punktfunk_core::quic::ColorInfo;
let full = color.full_range != 0;
let ds = match color.transfer {
ColorInfo::TRC_PQ if full => DataSpace::Bt2020Pq,
ColorInfo::TRC_PQ => DataSpace::Bt2020ItuPq,
ColorInfo::TRC_HLG if full => DataSpace::Bt2020Hlg,
ColorInfo::TRC_HLG => DataSpace::Bt2020ItuHlg,
_ => DataSpace::Bt709, // SDR — limited-range BT.709 video
};
i32::from(ds)
}
+26 -4
View File
@@ -1,16 +1,25 @@
//! Android video decode (android-only): pull HEVC access units from the connector and render them
//! to the SurfaceView via NDK `AMediaCodec` — hardware decode, zero per-frame JNI.
//! Android video decode (android-only): pull HEVC access units from the connector into NDK
//! `AMediaCodec` — hardware decode, zero per-frame JNI.
//!
//! The decoded frames reach glass through one of two present backends (see [`asc_presenter`] and
//! [`presenter`]). The default is the **ASurfaceControl** backend: the codec renders into an
//! `AImageReader` and each frame is composited onto an `ASurfaceControl` layer via a transaction
//! carrying a desired present time, scheduling against the panel's real present clock. The
//! **SurfaceView** presenter — `releaseOutputBufferAtTime` straight to the SurfaceView's window — is
//! the fallback for API < 29, an ASC init failure, or the `present_backend=surfaceview` sysprop.
//!
//! One-in/one-out: the host opens every stream with an IDR carrying VPS/SPS/PPS **in-band**, so the
//! decoder needs no out-of-band codec-specific data — we configure with mime + the negotiated
//! WxH (from [`NativeClient::mode`]) and feed each access unit as it arrives. The decode thread owns
//! the codec + window for its whole life; [`crate::session`] signals it to stop via the shared flag.
//! the codec + surface for its whole life; [`crate::session`] signals it to stop via the shared flag.
mod asc_presenter;
mod async_loop;
mod display;
mod latency;
mod presenter;
mod setup;
mod surface_control;
mod sync_loop;
mod vsync;
@@ -82,7 +91,14 @@ const NO_VIDEO_PATIENCE: std::time::Duration = std::time::Duration::from_millis(
/// Re-ask cadence once [`NO_VIDEO_PATIENCE`] has elapsed with still nothing received. Slow, because
/// this state is either self-healing on the first ask or not ours to heal — and each pass logs.
const NO_VIDEO_RETRY: std::time::Duration = std::time::Duration::from_millis(2000);
///
/// ⚠ Taken from core, NOT a local number. `FLUSH_COOLDOWN` (the jump-to-live rate limit) is 2000 ms,
/// and the host classifies a keyframe-recovery cadence by matching a cooldown's period ±10 % to
/// decide WHICH client failure it is looking at. The two are opposites — "I have received nothing"
/// versus "I am drowning in frames I cannot drain" — so while this was also 2000 ms the host
/// confidently reported the wrong one, and a black-screen field case was diagnosed as a slow decoder
/// for days (2026-08-20). Keeping the value in core is what stops the two drifting back together.
const NO_VIDEO_RETRY: std::time::Duration = punktfunk_core::client::NO_VIDEO_RETRY;
/// Whether low-latency mode uses the event-driven async decode loop (default) or the synchronous
/// poll loop. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop presents a
@@ -124,6 +140,12 @@ pub(crate) struct DecodeOptions {
/// named here is not necessarily the one the panel ends up in. The measured timeline spacing
/// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]).
pub panel_hz: i32,
/// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint),
/// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every
/// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space
/// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it
/// yet, and the backend falls back to the window buffer size.
pub surface_size: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -0,0 +1,425 @@
//! The `ASurfaceControl` compositor layer behind the ASurfaceControl presenter backend.
//!
//! This is the Android analogue of what the Apple client gets from `CAMetalDisplayLink` +
//! `preferredFrameLatency = 1`: a present path that schedules each frame against the panel's own
//! timeline and hands back the *real* present feedback, instead of the MediaCodec→SurfaceView→
//! BufferQueue path that predicts the latch and hopes the `OnFrameRendered` callbacks arrive.
//!
//! A `Layer` owns one `ASurfaceControl` created as a child of the SurfaceView's `ANativeWindow`;
//! the decoder renders into an `AImageReader` and the
//! presenter composites each acquired `AHardwareBuffer` onto this layer via an `ASurfaceTransaction`
//! that carries a desired present time (the single actuator both present modes drive) and an
//! acquire fence. Every applied transaction registers a one-shot completion callback that reports
//! the frame's real latch time and the *previous* buffer's release fence back through the decode
//! loop's event channel — the truthful present clock the cadence loop and the glass budget were
//! missing.
//!
//! Every `ASurface*` entry point is **API 29** — above the crate's minSdk-28 floor — so all are
//! `dlsym`-resolved from `libandroid.so`, exactly as [`crate::adpf`] and [`super::vsync`] resolve
//! their own >-floor symbols; a hard import of any of them would make `System.loadLibrary` fail on
//! every API-28 device even where this backend is never selected. Absent (or a null layer) ⇒
//! [`Layer::create`] returns `None` and the caller falls back to the SurfaceView presenter.
use ndk::hardware_buffer::HardwareBuffer;
use ndk::native_window::NativeWindow;
use std::ffi::c_void;
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{mpsc, Arc};
use super::async_loop::DecodeEvent;
// ---- Opaque native types (not in `ndk-sys 0.6`) ------------------------------------------------
#[repr(C)]
struct ASurfaceControl {
_p: [u8; 0],
}
#[repr(C)]
struct ASurfaceTransaction {
_p: [u8; 0],
}
#[repr(C)]
struct ASurfaceTransactionStats {
_p: [u8; 0],
}
/// `ARect` — the `setGeometry` source/destination rectangle (`android/native_window.h`).
#[repr(C)]
#[derive(Clone, Copy)]
struct ARect {
left: i32,
top: i32,
right: i32,
bottom: i32,
}
/// `ANATIVEWINDOW_TRANSFORM_IDENTITY` — no rotation/flip; the decoder already emits upright frames.
const TRANSFORM_IDENTITY: i32 = 0;
/// `ASURFACE_TRANSACTION_VISIBILITY_SHOW`.
const VISIBILITY_SHOW: i8 = 1;
// ---- The `dlsym`-resolved entry-point table ----------------------------------------------------
type CreateFromWindowFn = unsafe extern "C" fn(
*mut ndk_sys::ANativeWindow,
*const std::ffi::c_char,
) -> *mut ASurfaceControl;
type AcReleaseFn = unsafe extern "C" fn(*mut ASurfaceControl);
type TxnCreateFn = unsafe extern "C" fn() -> *mut ASurfaceTransaction;
type TxnDeleteFn = unsafe extern "C" fn(*mut ASurfaceTransaction);
type TxnApplyFn = unsafe extern "C" fn(*mut ASurfaceTransaction);
type TxnSetBufferFn = unsafe extern "C" fn(
*mut ASurfaceTransaction,
*mut ASurfaceControl,
*mut ndk_sys::AHardwareBuffer,
RawFd,
);
type TxnSetVisibilityFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i8);
type TxnSetZOrderFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i32);
type TxnSetGeometryFn = unsafe extern "C" fn(
*mut ASurfaceTransaction,
*mut ASurfaceControl,
*const ARect,
*const ARect,
i32,
);
type TxnSetDesiredPresentTimeFn = unsafe extern "C" fn(*mut ASurfaceTransaction, i64);
type TxnSetBufferDataSpaceFn =
unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, i32);
type TxnSetFrameRateFn =
unsafe extern "C" fn(*mut ASurfaceTransaction, *mut ASurfaceControl, f32, i8);
type OnCompleteCb = unsafe extern "C" fn(*mut c_void, *mut ASurfaceTransactionStats);
type TxnSetOnCompleteFn = unsafe extern "C" fn(*mut ASurfaceTransaction, *mut c_void, OnCompleteCb);
type StatsGetLatchTimeFn = unsafe extern "C" fn(*mut ASurfaceTransactionStats) -> i64;
type StatsGetPrevReleaseFenceFn =
unsafe extern "C" fn(*mut ASurfaceTransactionStats, *mut ASurfaceControl) -> RawFd;
struct Api {
create_from_window: CreateFromWindowFn,
ac_release: AcReleaseFn,
txn_create: TxnCreateFn,
txn_delete: TxnDeleteFn,
txn_apply: TxnApplyFn,
txn_set_buffer: TxnSetBufferFn,
txn_set_visibility: TxnSetVisibilityFn,
txn_set_z_order: TxnSetZOrderFn,
txn_set_geometry: TxnSetGeometryFn,
txn_set_present_time: TxnSetDesiredPresentTimeFn,
/// `setBufferDataSpace` is present from API 29 in practice but historically under-declared —
/// resolved optionally, so an SDR stream (which never touches it) works even where it is absent.
txn_set_dataspace: Option<TxnSetBufferDataSpaceFn>,
/// `setFrameRate` is **API 30** — optional, `None` on API 29.
txn_set_frame_rate: Option<TxnSetFrameRateFn>,
txn_set_on_complete: TxnSetOnCompleteFn,
stats_latch_time: StatsGetLatchTimeFn,
stats_prev_release_fence: StatsGetPrevReleaseFenceFn,
}
impl Api {
/// Resolve the whole `ASurface*` table from `libandroid.so`, or `None` on API < 29 (any required
/// symbol absent). The two optional entries (`setBufferDataSpace`, `setFrameRate`) do not gate.
fn resolve() -> Option<Api> {
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never
// closed — a process-lifetime handle). Each `dlsym` returns null when the symbol is absent
// (device below API 29), checked before transmuting the non-null pointer to its fn type.
unsafe {
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let req = |name: &std::ffi::CStr| -> Option<*mut c_void> {
let p = libc::dlsym(lib, name.as_ptr());
(!p.is_null()).then_some(p)
};
Some(Api {
create_from_window: std::mem::transmute::<*mut c_void, CreateFromWindowFn>(req(
c"ASurfaceControl_createFromWindow",
)?),
ac_release: std::mem::transmute::<*mut c_void, AcReleaseFn>(req(
c"ASurfaceControl_release",
)?),
txn_create: std::mem::transmute::<*mut c_void, TxnCreateFn>(req(
c"ASurfaceTransaction_create",
)?),
txn_delete: std::mem::transmute::<*mut c_void, TxnDeleteFn>(req(
c"ASurfaceTransaction_delete",
)?),
txn_apply: std::mem::transmute::<*mut c_void, TxnApplyFn>(req(
c"ASurfaceTransaction_apply",
)?),
txn_set_buffer: std::mem::transmute::<*mut c_void, TxnSetBufferFn>(req(
c"ASurfaceTransaction_setBuffer",
)?),
txn_set_visibility: std::mem::transmute::<*mut c_void, TxnSetVisibilityFn>(req(
c"ASurfaceTransaction_setVisibility",
)?),
txn_set_z_order: std::mem::transmute::<*mut c_void, TxnSetZOrderFn>(req(
c"ASurfaceTransaction_setZOrder",
)?),
txn_set_geometry: std::mem::transmute::<*mut c_void, TxnSetGeometryFn>(req(
c"ASurfaceTransaction_setGeometry",
)?),
txn_set_present_time: std::mem::transmute::<*mut c_void, TxnSetDesiredPresentTimeFn>(
req(c"ASurfaceTransaction_setDesiredPresentTime")?,
),
txn_set_dataspace: req(c"ASurfaceTransaction_setBufferDataSpace")
.map(|p| std::mem::transmute::<*mut c_void, TxnSetBufferDataSpaceFn>(p)),
txn_set_frame_rate: req(c"ASurfaceTransaction_setFrameRate")
.map(|p| std::mem::transmute::<*mut c_void, TxnSetFrameRateFn>(p)),
txn_set_on_complete: std::mem::transmute::<*mut c_void, TxnSetOnCompleteFn>(req(
c"ASurfaceTransaction_setOnComplete",
)?),
stats_latch_time: std::mem::transmute::<*mut c_void, StatsGetLatchTimeFn>(req(
c"ASurfaceTransactionStats_getLatchTime",
)?),
stats_prev_release_fence: std::mem::transmute::<
*mut c_void,
StatsGetPrevReleaseFenceFn,
>(req(
c"ASurfaceTransactionStats_getPreviousReleaseFenceFd",
)?),
})
}
}
}
/// The `ASurfaceControl` handle, reference-counted so it outlives every in-flight transaction. The
/// layer holds one `Arc`; each pending completion callback's context holds another. `release` is
/// called exactly once — when the layer is dropped AND the last outstanding callback has fired — so
/// a completion that lands after teardown never indexes a freed control (the render-callback
/// reclaim hazard, in the transaction world).
struct ScHandle {
sc: *mut ASurfaceControl,
release: AcReleaseFn,
}
// SAFETY: `sc` is only ever passed back to `ASurface*` C entry points (never dereferenced in Rust),
// and its release is serialised by the `Arc` refcount reaching zero on whichever thread drops last.
unsafe impl Send for ScHandle {}
// SAFETY: as above — the raw handle is opaque to Rust and only handed to the thread-safe `ASurface*`
// C API; shared read access across threads (the completion callback) never mutates it.
unsafe impl Sync for ScHandle {}
impl Drop for ScHandle {
fn drop(&mut self) {
// SAFETY: created by `createFromWindow`; the `Arc` guarantees this is the sole, final release
// and that no transaction or callback still references `sc`.
unsafe { (self.release)(self.sc) };
}
}
/// One presented transaction's real feedback, posted from the completion callback (a binder thread)
/// into the decode loop's event channel. The loop matches `seq` to the buffer it retired and frees
/// it once `prev_release_fence` signals.
pub(super) struct PresentComplete {
/// The presenter's monotonically increasing submit sequence for this transaction.
pub seq: u64,
/// SurfaceFlinger's latch instant for this frame (`CLOCK_MONOTONIC` ns) — the truthful present
/// clock: consecutive latches are one true panel period apart, and `latch release` is the
/// real `latch` stat, both of which the predicted path could only guess at.
pub latch_ns: i64,
/// The release fence for the buffer this transaction REPLACED (the previous frame on the
/// layer), or `None` when the platform reports none. The loop deletes that buffer's image with
/// this fence so it is returned to the reader's pool only once SurfaceFlinger is done with it.
pub prev_release_fence: Option<OwnedFd>,
}
/// The completion callback's per-transaction context, leaked as a raw pointer into
/// `setOnComplete` and reclaimed inside the callback (which fires exactly once per applied
/// transaction). Carries only `Send` data so the binder-thread callback is sound.
struct CompleteCtx {
tx: mpsc::Sender<DecodeEvent>,
seq: u64,
/// A shared reference to the layer's `ASurfaceControl`, needed to read the per-surface release
/// fence out of the stats. Holding the `Arc` keeps the control alive for the callback even if
/// the layer was already dropped.
sc: Arc<ScHandle>,
prev_fence_fn: StatsGetPrevReleaseFenceFn,
latch_fn: StatsGetLatchTimeFn,
}
/// The `ASurfaceTransaction_OnComplete` trampoline (a binder thread). Reclaims its leaked context,
/// reads the real latch time + the previous buffer's release fence, and forwards them to the decode
/// loop. Panic-free by construction (an unwind out of an `extern "C"` fn would abort the process).
unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTransactionStats) {
if context.is_null() {
return;
}
// SAFETY: `context` is the `Box<CompleteCtx>` leaked in `Layer::present`; the platform delivers
// it exactly once per applied transaction, so this single reclaim is correct.
let ctx = unsafe { Box::from_raw(context as *mut CompleteCtx) };
let latch_ns = if stats.is_null() {
0
} else {
// SAFETY: `stats` is valid for the duration of this callback (platform contract).
unsafe { (ctx.latch_fn)(stats) }
};
let prev_release_fence = if stats.is_null() {
None
} else {
// SAFETY: valid stats + the layer's live `ASurfaceControl`; a returned fd is owned by us
// and closed via `OwnedFd`. `-1` means no fence.
let fd = unsafe { (ctx.prev_fence_fn)(stats, ctx.sc.sc) };
// SAFETY: a non-negative fd returned by `getPreviousReleaseFenceFd` is a fresh owned fence
// descriptor whose ownership the API transfers to us; wrapping it in `OwnedFd` closes it.
(fd >= 0).then(|| unsafe { OwnedFd::from_raw_fd(fd) })
};
let _ = ctx.tx.send(DecodeEvent::PresentComplete(PresentComplete {
seq: ctx.seq,
latch_ns,
prev_release_fence,
}));
}
/// One `ASurfaceControl` layer, a child of the SurfaceView's window, that the presenter composites
/// decoded buffers onto. Owns nothing thread-shared; lives on and is dropped by the decode loop.
pub(super) struct Layer {
api: Api,
sc: Arc<ScHandle>,
/// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every
/// present — the destination rectangle the buffer is scaled to fill. Live rather than captured
/// because the view resizes under a surface that is never recreated (see `dest`).
surface_size: Arc<AtomicU64>,
/// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the
/// view when video started): the window's own buffer geometry, the best remaining guess.
fallback_w: i32,
fallback_h: i32,
/// `true` once the first transaction has made the layer visible + set its z-order + frame rate.
configured: bool,
}
impl Layer {
/// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on
/// API < 29 / a null layer — the caller then uses the SurfaceView presenter.
///
/// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the
/// child layer is composited into, which is the display footprint of the (aspect-fitted) video
/// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer
/// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) —
/// using it shrank the picture to the top-left corner. It is read fresh on every present
/// because that view RESIZES mid-stream under a surface that is never recreated: the stream
/// screen hides the system bars and switches on cutout drawing a frame or two after
/// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the
/// view yet) falls back to the buffer size as the best remaining guess.
pub(super) fn create(window: &NativeWindow, surface_size: Arc<AtomicU64>) -> Option<Layer> {
let api = Api::resolve()?;
// SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a
// static NUL-terminated string; the call returns null on failure (checked).
let sc =
unsafe { (api.create_from_window)(window.ptr().as_ptr(), c"punktfunk-video".as_ptr()) };
if sc.is_null() {
log::warn!("asc: createFromWindow returned null — falling back to SurfaceView");
return None;
}
let fallback_w = window.width().max(1);
let fallback_h = window.height().max(1);
log::info!(
"asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})",
crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)),
);
Some(Layer {
sc: Arc::new(ScHandle {
sc,
release: api.ac_release,
}),
api,
surface_size,
fallback_w,
fallback_h,
configured: false,
})
}
/// The destination rectangle for this present: the live view size, or the window's buffer
/// geometry while Kotlin has reported nothing.
fn dest(&self) -> (i32, i32) {
crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed))
.unwrap_or((self.fallback_w, self.fallback_h))
}
/// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes
/// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot
/// completion that reports the real latch + the previous buffer's release fence on `ev_tx`,
/// tagged with `seq`. `dataspace` is the `ADataSpace` value (`0` = leave the layer default —
/// only the `setBufferDataSpace`-less API-29 fallback ever presents untagged).
/// `frame_rate` votes the layer's rate once (`0.0` skips). Returns `false` if the transaction
/// could not be created (the caller then frees the buffer itself).
#[allow(clippy::too_many_arguments)]
pub(super) fn present(
&mut self,
buffer: &HardwareBuffer,
src_w: i32,
src_h: i32,
acquire_fence: Option<OwnedFd>,
desired_present_ns: i64,
dataspace: i32,
frame_rate: f32,
seq: u64,
ev_tx: &mpsc::Sender<DecodeEvent>,
) -> bool {
// SAFETY: `txn_create` returns a fresh transaction or null; every setter below takes that
// transaction + this layer's live `sc` + valid arguments; `apply`/`delete` consume it once.
unsafe {
let txn = (self.api.txn_create)();
if txn.is_null() {
// The acquire fence would leak if we returned without consuming it.
drop(acquire_fence);
return false;
}
let sc = self.sc.sc;
let fence_fd = acquire_fence
.map(std::os::fd::IntoRawFd::into_raw_fd)
.unwrap_or(-1);
(self.api.txn_set_buffer)(txn, sc, buffer.as_ptr(), fence_fd);
let src = ARect {
left: 0,
top: 0,
right: src_w.max(1),
bottom: src_h.max(1),
};
let (dest_w, dest_h) = self.dest();
let dst = ARect {
left: 0,
top: 0,
right: dest_w,
bottom: dest_h,
};
(self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY);
if dataspace != 0 {
if let Some(f) = self.api.txn_set_dataspace {
f(txn, sc, dataspace);
}
}
if !self.configured {
(self.api.txn_set_visibility)(txn, sc, VISIBILITY_SHOW);
(self.api.txn_set_z_order)(txn, sc, 0);
// Declare the layer as fixed-rate video at the source rate (compatibility 1 =
// FIXED_SOURCE) so a compliant display aligns its refresh to it. Best-effort: an
// LTPO governor may still run "video" content below its own floor for power (the
// NP3 does — no app-side rate hint raises its render-range floor; the display's
// Minimum-refresh-rate system setting is the only lever there).
if frame_rate > 0.0 {
if let Some(f) = self.api.txn_set_frame_rate {
f(txn, sc, frame_rate, 1);
}
}
self.configured = true;
}
(self.api.txn_set_present_time)(txn, desired_present_ns);
// One-shot completion context, reclaimed inside the callback. The `Arc` clone keeps the
// control alive for the callback even past the layer's own drop.
let ctx = Box::into_raw(Box::new(CompleteCtx {
tx: ev_tx.clone(),
seq,
sc: self.sc.clone(),
prev_fence_fn: self.api.stats_prev_release_fence,
latch_fn: self.api.stats_latch_time,
}));
(self.api.txn_set_on_complete)(txn, ctx as *mut c_void, on_complete);
(self.api.txn_apply)(txn);
(self.api.txn_delete)(txn);
}
true
}
}
@@ -48,6 +48,9 @@ pub(super) fn run_sync(
present_priority: _,
smooth_buffer: _,
panel_hz: _,
// The ASurfaceControl backend is async-loop only; the sync loop renders straight to the
// SurfaceView, so it never needs the view's on-screen size.
surface_size: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
+68 -6
View File
@@ -30,6 +30,20 @@ use jni::EnvUnowned;
mod adpf;
#[cfg(target_os = "android")]
mod audio;
// The Skia console UI host (design/android-skia-console-port.md): the shared `pf-console-ui`
// shell over EGL/GLES, on every ABI (the armv7 Skia archive is self-hosted — see Cargo.toml).
#[cfg(target_os = "android")]
mod console;
// "Send logs to host": the log-ring upload (`pf-client-core` is Android-target-only here).
#[cfg(target_os = "android")]
mod logs;
// The RESOLVED audio format + its ms ⇄ sample arithmetic, split out of `audio` and — unlike it —
// ungated, because that arithmetic is what a rate the ladder does not divide gets wrong (44 100 Hz
// used to come out 2.3 % off in every direction at once) and it must be provable without a phone.
// Nothing in it touches AAudio. `test`-gated for the host build on top of the Android one so the
// off-device leg still compiles and runs the proof; `audio` is its only non-test user.
#[cfg(any(target_os = "android", test))]
mod audio_format;
#[cfg(target_os = "android")]
mod decode;
// Ungated: pure `mdns-sd` + `jni`, so the browse + its JNI seam link into the host workspace build
@@ -49,22 +63,58 @@ mod wol;
// it off the main thread to light saved-host "online" pips independently of mDNS.
mod probe;
/// Initialize `android_logger` once when the JVM loads the library. Logs land in logcat under the
/// `punktfunk` tag. Core `tracing` events (transport warnings: socket-buffer clamp, QoS failures)
/// arrive here too: tracing's "log" feature — declared explicitly in Cargo.toml rather than relied
/// on via quinn's defaults — forwards them as `log` records since no tracing subscriber is ever
/// installed. Android-only — there is no JVM (and no logcat) on the host build.
/// Every `log` record, teed: to logcat (via [`android_logger::AndroidLogger`]) AND into
/// `pf_client_core::logring` — the source for the console's "Send logs to host" action
/// ([`logs`]). The ring line mirrors the desktop `ring_layer`'s shape (wallclock, level,
/// target, message) so a bundle reads the same on the host's Logs page whichever client
/// sent it. Both sinks share the crate's Info ceiling — the field ring gets exactly what
/// logcat gets, which also keeps per-frame DEBUG chatter out of it by construction.
#[cfg(target_os = "android")]
struct RingTee(android_logger::AndroidLogger);
#[cfg(target_os = "android")]
impl log::Log for RingTee {
fn enabled(&self, metadata: &log::Metadata) -> bool {
self.0.enabled(metadata)
}
fn log(&self, record: &log::Record) {
self.0.log(record);
pf_client_core::logring::note(format!(
"{} {:5} {} {}",
pf_client_core::logring::wallclock(),
record.level().as_str(),
record.target(),
record.args()
));
}
fn flush(&self) {
self.0.flush();
}
}
/// Initialize logging once when the JVM loads the library: logcat under the `punktfunk` tag,
/// teed into the client log ring (see [`RingTee`]). Core `tracing` events (transport warnings:
/// socket-buffer clamp, QoS failures) arrive here too: tracing's "log" feature — declared
/// explicitly in Cargo.toml rather than relied on via quinn's defaults — forwards them as
/// `log` records since no tracing subscriber is ever installed. Android-only — there is no
/// JVM (and no logcat) on the host build.
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn JNI_OnLoad(
_vm: *mut jni::sys::JavaVM,
_reserved: *mut std::ffi::c_void,
) -> jint {
android_logger::init_once(
let logcat = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag("punktfunk"),
);
// `set_boxed_logger` (unlike `init_once`) does not set the max level itself.
if log::set_boxed_logger(Box::new(RingTee(logcat))).is_ok() {
log::set_max_level(log::LevelFilter::Info);
}
log::info!(
"punktfunk_android loaded (core ABI v{})",
punktfunk_core::ABI_VERSION
@@ -92,3 +142,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_coreVersion<'loca
env.with_env(|env| env.new_string(env!("CARGO_PKG_VERSION")))
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeConsoleAvailable(): Boolean` — whether this `.so` carries the Skia
/// console host ([`console`]). Kotlin asks before it calls any `nativeConsole*` symbol, so a
/// build that ever drops the host on some ABI again degrades to the touch UI rather than an
/// `UnsatisfiedLinkError`. Today: every Android ABI.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConsoleAvailable(
_env: EnvUnowned,
_this: JObject,
) -> jni::sys::jboolean {
cfg!(target_os = "android")
}
+27
View File
@@ -0,0 +1,27 @@
//! JNI seam for "Send logs to host": hand Kotlin the client's recent log ring (fed by the
//! [`crate::RingTee`] logcat tee) rendered as one text bundle. The UPLOAD stays on the
//! Kotlin side — its mTLS OkHttp client (`mtlsHttpClient`, the library/art path) already
//! owns HTTPS-to-the-pinned-host on this platform, and `logring::send_to_host`'s ureq
//! agent is deliberately desktop-only. Android-gated (unlike [`crate::wol`]/[`crate::probe`])
//! because `pf-client-core` is an Android-target dependency of this crate.
use jni::errors::LogErrorAndDefault;
use jni::objects::{JObject, JString};
use jni::EnvUnowned;
/// `NativeBridge.nativeRenderLogs(header): String` — the ring as one text bundle, oldest
/// first, prefixed by `header` (the Kotlin side's identity line) and an eviction note when
/// the ring wrapped. Never empty (the header line is always present); cheap enough for any
/// thread, though the caller is about to do network anyway.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeRenderLogs<'local>(
mut env: EnvUnowned<'local>,
_this: JObject<'local>,
header: JString<'local>,
) -> JString<'local> {
env.with_env(|env| {
let header: String = header.try_to_string(env)?;
env.new_string(pf_client_core::logring::render(&header))
})
.resolve::<LogErrorAndDefault>()
}
+327 -8
View File
@@ -102,9 +102,131 @@ fn force_parts_sysprop() -> bool {
false
}
/// The rates this session may ask for when the one the user chose will not open, best first.
///
/// **Down the requested rate's own FAMILY, then the 48 kHz floor.** The two families
/// ([`punktfunk_core::audio::pcm::rate_is_supported`]) are 44.1 / 88.2 / 176.4 kHz and 48 / 96 kHz,
/// and within a family the lower rates are the same material at half the samples — a 176.4 kHz
/// interface that will not open is overwhelmingly likely to be an 88.2 or 44.1 kHz one, and asking
/// there next is asking for the rate the endpoint most plausibly runs at.
///
/// **48 kHz terminates every ladder, including the 44.1 family's**, and that crossing is deliberate
/// rather than an oversight. It is the rate every Android output grants, it is the rate this
/// protocol has always run, and the alternative to a 48 kHz *lossless* session is a 48 kHz *Opus*
/// one — the same rate with a lossy stage added. Nothing is resampled by this decision: the host
/// captures at the rate it answers with, or declines (§8.2/§8.3), so a 44.1 kHz-locked endpoint
/// answers a 48 kHz request with Opus rather than with a quiet upsample.
///
/// The requested rate is the first rung, so an openable rate is asked for unchanged and a default
/// session's ladder is one rung long.
fn rate_fallback_ladder(rate_hz: u32) -> &'static [u32] {
const HZ48: u32 = punktfunk_core::audio::SAMPLE_RATE_HZ;
match rate_hz {
176_400 => &[176_400, 88_200, 44_100, HZ48],
88_200 => &[88_200, 44_100, HZ48],
44_100 => &[44_100, HZ48],
96_000 => &[96_000, HZ48],
// 48 kHz itself, and — via the `rate_is_supported` guard in the caller — nothing else.
_ => &[HZ48],
}
}
/// Resolve the audio format this `Hello` should ASK for, from what Kotlin's setting requested —
/// after proving this device can actually open it.
///
/// This is `design/hi-res-audio.md` §7's rule made mechanical: *"a client that cannot open a
/// 96 kHz output must not set `CLIENT_CAP_AUDIO_HIRES`"*. It has to happen here, before the
/// handshake, because after it there is no recovery: AAudio grants an explicitly-requested rate or
/// fails the open (it never substitutes), the host does not renegotiate the plane mid-session
/// (§6), and the only ways to play a wire of one rate through a stream of another are the wrong
/// speed or a resampler nobody asked for — which §9 forbids in as many words ("say so and fall
/// back, not resample quietly"). So the fall back happens where falling back is still free: in the
/// request.
///
/// **Every rung above the floor is probed, and admitting the 44.1 kHz family made that matter
/// more, not less.** When the ladder was 96 → 48 there was one uncertain rate; now there are four,
/// and their odds are nothing alike — 44 100 Hz is granted by very nearly every Android output,
/// 176 400 Hz by very nearly none, and 88 200 Hz by whatever the HAL happens to think. None of
/// that is inferable from the number, so [`crate::audio::output_rate_is_openable`] opens a stream
/// and reads back what it was granted, once per rung, until one holds.
///
/// Dropping the RATE keeps the depth, so a device that refuses the rate still gets a 24-bit
/// lossless session rather than being pushed all the way back to Opus — the depth is where the
/// plane earns its bandwidth anyway (and it is the half that is audible at all: §12).
///
/// The 48 kHz floor is never probed. It is universally supported, and the DEPTH never reaches
/// AAudio at all (the device is opened as f32 on both planes — see `crate::audio`), so there is
/// nothing about 16-vs-24-bit for a probe to discover. An ordinary session therefore opens no
/// stream here and pays nothing.
///
/// # ⚠⚠ "Not asking" is `(0, 0)`, and it is NOT `(48 000, 16)`
///
/// Core's `advertised_client_caps` sets `CLIENT_CAP_AUDIO_HIRES` when **either field is non-zero**
/// — it keys on *the caller specified a format*, not on *the format differs from the default*, and
/// deliberately: 48 kHz/16-bit is the cheapest lossless rung as well as the legacy pair, so a
/// "differs from the default" rule would make it the one rung on the ladder nobody could ask for.
///
/// So returning the legacy-looking `(48 000, 16)` for a user who chose **Standard (Opus)** does not
/// mean "no request" — it advertises the capability, the host's gate accepts 48 kHz/16-bit as a
/// perfectly supported format, and the host then silently gives that user the lossless `0xD3` plane
/// at 1.5 Mbps in place of 256 kbps of Opus. This returned exactly that pair until it was caught by
/// comparing all four clients; the desktop client and every pre-v24 `punktfunk_connect_ex*` send
/// `(0, 0)`, and so does this now.
///
/// ⚠⚠ The reach of that slip grew on 2026-08-17: `PUNKTFUNK_AUDIO_HIRES` went default-ON, so where
/// this used to need a host whose operator had opted in, it now lands on every host that has not
/// deliberately opted out.
///
/// `(0, 0)` is also what keeps the `Hello` byte-identical to a legacy one, because the wire encodes
/// an explicit 48 000/16 the same as absent — the difference lives entirely in the capability bit.
fn resolve_requested_audio_format(rate_hz: u32, bits: u8, channels: u8) -> (u32, u8) {
const HZ48: u32 = punktfunk_core::audio::SAMPLE_RATE_HZ;
/// "This session did not ask for the lossless plane" — see the ⚠⚠ section above for why this
/// is a pair of zeroes and not the legacy 48 000/16.
const UNSPECIFIED: (u32, u8) = (0, 0);
// A format core would not carry — including Kotlin's `0`/`0` for the Opus setting — asks for
// nothing, rather than being an error: the request is a preference, and an unrecognized one
// must not block a connect. The rate set comes from core rather than being re-expressed here,
// so the host's gate and every client's request validation cannot drift apart.
if !punktfunk_core::audio::pcm::depth_is_supported(bits)
|| !punktfunk_core::audio::pcm::rate_is_supported(rate_hz)
{
return UNSPECIFIED;
}
let granted = rate_fallback_ladder(rate_hz)
.iter()
.copied()
// `HZ48` short-circuits the probe rather than being trusted after one: it is the ladder's
// floor, so a probe there could only turn a working session into no lossless session at
// all — and it is the rate a failed probe would have fallen back TO.
.find(|&hz| hz == HZ48 || audio_rate_is_openable(hz, channels))
// Unreachable while every ladder ends at `HZ48`; the belt is here so a future rung added
// above the floor cannot silently produce an unrequestable format.
.unwrap_or(HZ48);
if granted != rate_hz {
log::warn!(
"audio: this device will not open a {rate_hz} Hz output, so the session asks for {granted} Hz / {bits}-bit instead — the wire is only ever offered a format this client has proved it can play"
);
}
(granted, bits)
}
#[cfg(target_os = "android")]
fn audio_rate_is_openable(rate_hz: u32, channels: u8) -> bool {
crate::audio::output_rate_is_openable(rate_hz, channels)
}
/// Off-device (the host `cargo build --workspace` leg, where there is no AAudio at all): nothing
/// can be proved, so nothing is claimed. The caller falls back to the legacy rate, which is the
/// safe answer for a build that never runs on a phone anyway.
#[cfg(not(target_os = "android"))]
fn audio_rate_is_openable(_rate_hz: u32, _channels: u8) -> bool {
false
}
/// `NativeBridge.nativeConnect(host, port, w, h, hz, certPem, keyPem, pinHex, bitrateKbps,
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, preferredCodec, timeoutMs, launch,
/// deviceName): Long`.
/// compositorPref, gamepadPref, hdrEnabled, audioChannels, audioRateHz, audioBits, preferredCodec,
/// timeoutMs, launch, deviceName): Long`.
/// `launch` (empty ⇒ none) is a store-qualified library id to boot straight into a game.
/// `deviceName` (empty ⇒ none) rides the Hello as `name` — what the host's pending-approval list
/// and trust store show for this device (Kotlin passes `Build.MODEL`, its `nativePair` convention).
@@ -113,6 +235,14 @@ fn force_parts_sysprop() -> bool {
/// `bitrateKbps` 0 = host default. `compositorPref`/`gamepadPref` are `CompositorPref`/`GamepadPref`
/// wire bytes (0 = Auto; unknown → Auto). `audioChannels` is the requested surround layout (2/6/8;
/// normalized, anything else → stereo) — the host clamps it and the resolved count drives playback.
/// `audioRateHz`/`audioBits` are the audio FORMAT asked for. **`0`/`0` — and anything unrecognized
/// — is "did not ask", the legacy Opus request every build has made**; any pair core can carry asks
/// the host for the lossless `0xD3` plane, `48000`/`16` INCLUDED (that is the cheapest lossless
/// rung, not a spelling of "default" — see [`resolve_requested_audio_format`], which is where
/// getting this backwards silently upgraded every Opus session). Only a request; the host's gate may
/// answer Opus regardless, and this device may not be able to open the rate at all, which is what
/// [`resolve_requested_audio_format`] settles HERE rather than letting the session negotiate a wire
/// it cannot play.
/// `preferredCodec` is the soft codec preference wire byte (0 = Auto). `timeoutMs` is the handshake
/// budget: the normal path passes a short value, the no-PIN "request access" path a long one (≥ the
/// host's approval-park window) so a slow operator approval lands on this same parked connection
@@ -137,6 +267,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
multi_slice_ok: jboolean,
frame_parts_ok: jboolean,
audio_channels: jint,
audio_rate_hz: jint,
audio_bits: jint,
video_codecs: jint,
preferred_codec: jint,
timeout_ms: jint,
@@ -222,7 +354,21 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
height: height as u32,
refresh_hz: refresh_hz as u32,
};
match NativeClient::connect(
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1); anything else is stereo. The
// host clamps it and echoes the resolved count in `connector.audio_channels`, which drives the
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`).
let audio_channels =
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8);
// The audio format, downgraded to something this device has PROVED it can open before the
// `Hello` carries it — see `resolve_requested_audio_format` for why it cannot wait until
// playback. `clamp` first: a negative jint from a corrupted setting must not wrap into a
// plausible rate.
let (audio_rate_hz, audio_bits) = resolve_requested_audio_format(
audio_rate_hz.max(0) as u32,
audio_bits.clamp(0, u8::MAX as jint) as u8,
audio_channels,
);
match NativeClient::connect_with_audio_format(
&host,
port as u16,
mode,
@@ -248,11 +394,15 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
} else {
0
}),
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
// normalizes to stereo here.
punktfunk_core::audio::normalize_channels(audio_channels.clamp(0, u8::MAX as jint) as u8),
audio_channels,
// The audio format this session ASKS for (resolved above). A non-default pair is what
// makes core set `CLIENT_CAP_AUDIO_HIRES` in the `Hello` — capable AND the user turned it
// on, the `VIDEO_CAP_444` precedent — and it is answered by the host re-formatting the
// wire, so it must never be advertised on a device that cannot open the output. The host
// may still decline; `connector.audio_codec`/`audio_sample_rate_hz`/`audio_bits` are what
// actually happened, and `crate::audio` opens the device from those, never from these.
audio_rate_hz,
audio_bits,
// Codecs this device can decode, ranked on the Kotlin side (`VideoDecoders.decodableCodecBits`:
// H.264 + HEVC always, AV1 when a real `video/av01` decoder exists — AMediaCodec is
// mime-driven, see `codec_mime`). Mask to the known bits and fall back to the pre-AV1
@@ -301,6 +451,10 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// Handshake budget from Kotlin: ~10 s for a normal connect, ~185 s for "request access"
// (the host parks the connection until the operator approves the device — see ConnectScreen).
Duration::from_millis(timeout_ms.max(0) as u64),
// The Kotlin side cancels by dropping the result (`Dial.cancelled`), not by aborting
// the dial — its connect runs on a pool thread, so a parked one costs a thread, not a
// stuck UI. Wire a flag through here if that ever stops being true.
None,
) {
Ok(client) => {
let handle = SessionHandle {
@@ -316,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// A fresh session is never muted (mute is per-session UI state, not a setting).
mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)),
access_seq: std::sync::atomic::AtomicU32::new(0),
// Reported by Kotlin at `surfaceCreated` and on every resize after it.
surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
};
Box::into_raw(Box::new(handle)) as jlong
}
@@ -490,3 +646,166 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativePair<'local
})
.resolve::<LogErrorAndDefault>()
}
#[cfg(test)]
mod tests {
use super::*;
use punktfunk_core::audio::pcm::{BITS_16, BITS_24};
use punktfunk_core::audio::SAMPLE_RATE_HZ;
/// The rule this leg exists to enforce: the `Hello` never asks for an audio format this device
/// has not proved it can open, because after the handshake there is no way back — AAudio grants
/// an explicit rate or fails the open, the host does not renegotiate the plane mid-session, and
/// playing a 96 kHz wire through a 48 kHz stream is not a fallback, it is the wrong audio.
///
/// Off-device (this test's target) `audio_rate_is_openable` answers `false` for everything, so
/// what is pinned here is the DOWNGRADE, which is the half that has to be right: a device that
/// cannot do the rate still gets a lossless session at 48 kHz rather than being pushed all the
/// way back to Opus, and the depth — the thing lossless is actually for — survives.
#[test]
fn an_unopenable_rate_is_downgraded_before_the_hello_and_keeps_its_depth() {
// 48 kHz/16-bit is the cheapest LOSSLESS rung, not a way of spelling "default" — asking
// for it explicitly passes through and probes nothing. What a default session sends is
// `(0, 0)`, pinned in `an_opus_session_asks_for_nothing_and_a_lossless_one_asks_for_
// something`, and conflating the two is what silently upgraded every Opus user.
assert_eq!(
resolve_requested_audio_format(SAMPLE_RATE_HZ, BITS_16, 2),
(SAMPLE_RATE_HZ, BITS_16)
);
// 48 kHz is never probed, so 48/24 lossless survives even where nothing can be opened.
assert_eq!(
resolve_requested_audio_format(SAMPLE_RATE_HZ, BITS_24, 2),
(SAMPLE_RATE_HZ, BITS_24)
);
// Every rung above the floor IS probed, is refused here, and lands on 48 kHz with the
// depth intact — including the whole 44.1 kHz family, which this pass admitted. AAudio
// never substitutes a rate, so a device that would not grant 176 400 Hz and was asked for
// it anyway is silence, not a slower session.
for rate in [44_100u32, 88_200, 96_000, 176_400] {
assert_eq!(
resolve_requested_audio_format(rate, BITS_24, 2),
(SAMPLE_RATE_HZ, BITS_24),
"{rate} Hz should have fallen to 48 kHz and kept 24-bit"
);
}
// Surround asks exactly as stereo does. The lossless plane was stereo-only while a
// surround frame did not fit a datagram; the frame ladder is channel-aware, the host
// decides, and this leg's only job is to prove the OUTPUT opens (at the layout it will
// actually be opened with).
assert_eq!(
resolve_requested_audio_format(SAMPLE_RATE_HZ, BITS_24, 6),
(SAMPLE_RATE_HZ, BITS_24)
);
}
/// A settings string, a profile written by a newer build, or a corrupted preference must never
/// reach the wire as a format the plane cannot carry — and must never block a connect either.
/// Every one resolves to the "did not ask" sentinel, which every host can answer.
///
/// The rate set is `pcm::rate_is_supported`'s, not a second copy of it: 44 100 Hz used to sit
/// in this table because §4.1's integer samples-per-millisecond arithmetic could not express
/// it, and the day that stopped being true a locally re-expressed set would have kept refusing
/// it with a stale reason.
#[test]
fn an_unrepresentable_request_falls_back_instead_of_failing() {
for (rate, bits) in [
(0, 0), // Kotlin's Opus setting, and its "unset"
(22_050, BITS_24), // below the ladder — a rate this protocol never negotiates
(192_000, BITS_24), // out by §3's scope decision, not by any arithmetic
(384_000, BITS_24), // above anything anyone has asked for
(SAMPLE_RATE_HZ, 32), // 32-bit float is deliberately not on the wire
(SAMPLE_RATE_HZ, 8), // not a depth this plane carries
(176_400, 32), // a carried rate cannot rescue an uncarried depth
] {
assert_eq!(
resolve_requested_audio_format(rate, bits, 2),
(0, 0),
"{rate} Hz / {bits}-bit should have asked for nothing at all"
);
}
}
/// ⚠⚠ **The one that decides whether a user who chose Opus is quietly given 1.5 Mbps of PCM.**
///
/// Core's `advertised_client_caps` sets `CLIENT_CAP_AUDIO_HIRES` when **either** field of the
/// pair below is non-zero. It keys on "a format was specified" rather than "the format differs
/// from the default", and deliberately: 48 kHz/16-bit is the legacy pair AND the cheapest
/// lossless rung, so the other rule would make that rung the one nobody could request.
///
/// The consequence is that `(48 000, 16)` is not a way of saying "default" — it is a request,
/// the host's gate accepts it as a supported format, and the user who chose Standard gets the
/// lossless `0xD3` plane instead of Opus on every host that has not opted out of it (which,
/// since the host gate went default-ON on 2026-08-17, is all of them). Nothing surfaces it: the
/// settings screen shows what was asked for, and a granted
/// session and a declined one look identical from there. This function returned that pair until
/// all four clients were compared against each other.
///
/// The rule is restated here rather than called, because it is private to core; core's own
/// tests pin the other half of it. What this test owns is that **this** client never hands it a
/// non-zero pair unless the user asked for one.
#[test]
fn an_opus_session_asks_for_nothing_and_a_lossless_one_asks_for_something() {
// Core's rule, verbatim: `audio_rate_hz != 0 || audio_bits != 0`.
let asks_for_hires = |(rate_hz, bits): (u32, u8)| rate_hz != 0 || bits != 0;
assert!(
!asks_for_hires(resolve_requested_audio_format(0, 0, 2)),
"the default session must not advertise the capability"
);
// Surround changes nothing about it: the plane is negotiated by format, not by layout.
assert!(!asks_for_hires(resolve_requested_audio_format(0, 0, 8)));
// And every rung the settings screen offers does ask — otherwise the setting is inert.
// The 48 kHz rows resolve unprobed; the rest fall to 48 kHz off-device (see above) and
// still ask, because the fallback keeps the DEPTH and a 24-bit request is a real one.
for rate in [44_100u32, SAMPLE_RATE_HZ, 88_200, 96_000, 176_400] {
let wire = resolve_requested_audio_format(rate, BITS_24, 2);
assert!(
asks_for_hires(wire),
"{rate} Hz / 24-bit resolved to {wire:?}, which asks for nothing"
);
assert_eq!(wire.1, BITS_24, "the depth must survive every fallback");
}
}
/// The fallback ladder's shape, which decides what a device that refuses the user's rate is
/// asked for next — and which is the only place this client makes a quality choice on the
/// user's behalf, so it is worth pinning rather than reading.
#[test]
fn the_rate_ladder_descends_its_own_family_and_ends_at_the_48_khz_floor() {
for rate in [44_100u32, SAMPLE_RATE_HZ, 88_200, 96_000, 176_400] {
let ladder = rate_fallback_ladder(rate);
assert_eq!(ladder[0], rate, "{rate} Hz must ask for itself first");
assert_eq!(
ladder.last().copied(),
Some(SAMPLE_RATE_HZ),
"{rate} Hz must end at the floor every Android output grants"
);
for &rung in ladder {
assert!(
punktfunk_core::audio::pcm::rate_is_supported(rung),
"{rung} Hz is on {rate} Hz's ladder but is not a rate the plane carries"
);
}
// Strictly descending, so a fallback is never an upgrade in cost — except for the
// 48 kHz floor itself, which is above 44 100 and is the crossing the ladder makes on
// purpose (see `rate_fallback_ladder`).
for w in ladder.windows(2) {
assert!(
w[1] < w[0] || w[1] == SAMPLE_RATE_HZ,
"{rate} Hz's ladder goes up at {:?}",
w
);
}
}
// The 44.1 family stays in the 44.1 family for as long as it can: a 176.4 kHz endpoint
// that will not open is far likelier to be an 88.2 or 44.1 kHz one than a 96 kHz one.
assert_eq!(
rate_fallback_ladder(176_400),
&[176_400, 88_200, 44_100, SAMPLE_RATE_HZ]
);
assert_eq!(rate_fallback_ladder(96_000), &[96_000, SAMPLE_RATE_HZ]);
// A default session's ladder is one rung, so it opens no probe stream at all.
assert_eq!(rate_fallback_ladder(SAMPLE_RATE_HZ), &[SAMPLE_RATE_HZ]);
}
}
+58 -1
View File
@@ -26,7 +26,7 @@ mod probe;
use punktfunk_core::client::NativeClient;
use std::panic::AssertUnwindSafe;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
@@ -87,6 +87,37 @@ pub(crate) struct SessionHandle {
/// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update
/// (the host's expiry warnings) arrived without holding a blocking event thread.
pub(crate) access_seq: AtomicU32,
/// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by
/// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback
/// sends, read by the ASurfaceControl presenter before each present.
///
/// Shared and live rather than a start-time parameter because the view RESIZES under a surface
/// that is never recreated: hiding the system bars and switching the window to
/// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`,
/// and each one grows the video view. A destination rect captured once at creation then keeps
/// compositing the picture at its old, smaller size anchored at the layer's origin — the
/// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer
/// falls back to the window's buffer geometry.
pub surface_size: Arc<AtomicU64>,
}
/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a
/// single atomic load and can never see a torn pair (a new width against an old height).
/// Non-positive values pack as `0`, the "not reported yet" sentinel.
pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 {
if w <= 0 || h <= 0 {
return 0;
}
((w as u64) << 32) | (h as u64 & 0xffff_ffff)
}
/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> {
if packed == 0 {
return None;
}
Some((((packed >> 32) as u32) as i32, (packed as u32) as i32))
}
struct VideoThread {
@@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> {
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::{pack_surface_size, unpack_surface_size};
/// The pair the presenter reads as one atomic load must survive the round trip — including a
/// size wider than a signed 16-bit value, which every panel this runs on now is.
#[test]
fn surface_size_round_trips() {
assert_eq!(
unpack_surface_size(pack_surface_size(2800, 1260)),
Some((2800, 1260))
);
assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1)));
}
/// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back
/// to the window's buffer geometry rather than composing into an empty rectangle.
#[test]
fn non_positive_sizes_are_the_sentinel() {
assert_eq!(pack_surface_size(0, 0), 0);
assert_eq!(pack_surface_size(1920, 0), 0);
assert_eq!(pack_surface_size(-1, 1080), 0);
assert_eq!(unpack_surface_size(0), None);
}
}
+62 -5
View File
@@ -30,6 +30,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
present_priority: jni::sys::jint,
smooth_buffer: jni::sys::jint,
panel_fps: jni::sys::jint,
surface_w: jni::sys::jint,
surface_h: jni::sys::jint,
) {
use super::VideoThread;
use std::sync::atomic::AtomicBool;
@@ -70,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
let client = h.client.clone();
let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
// Seed the live view size with what the view measures right now; `surfaceChanged` keeps it
// current from here on (the bars hide and the cutout mode changes AFTER this call).
h.surface_size.store(
super::pack_surface_size(surface_w, surface_h),
std::sync::atomic::Ordering::Relaxed,
);
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature,
@@ -78,6 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
present_priority,
smooth_buffer,
panel_hz: panel_fps,
surface_size: h.surface_size.clone(),
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
@@ -89,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
.resolve::<LogErrorAndDefault>()
}
/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s
/// on-screen pixel size, re-reported on every `surfaceChanged`.
///
/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the
/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars
/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which
/// grow it. Without this the layer would keep painting the picture at its start-up size, in the
/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture).
/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo`
/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni`
/// + an atomic store, so it links on the host build too.
#[unsafe(no_mangle)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize(
_env: EnvUnowned,
_this: JObject,
handle: jlong,
width: jni::sys::jint,
height: jni::sys::jint,
) {
jni_guard((), || {
let packed = super::pack_surface_size(width, height);
if handle == 0 || packed == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
h.surface_size
.store(packed, std::sync::atomic::Ordering::Relaxed);
})
}
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
@@ -174,12 +215,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
}
/// `NativeBridge.nativeVideoStats(handle): DoubleArray?` — drain ~1 s of decode stats for the HUD
/// (unified stats spec, `design/stats-unification.md`). Returns 35 doubles
/// (unified stats spec, `design/stats-unification.md`). Returns 38 doubles
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive,
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs]`
/// feedP50Ms, codecP50Ms, skippedOverflowWindow, audioBufferMs, audioAvOffsetMs, audioCodec,
/// audioRateHz, audioBits]`
/// (the flags are 1.0/0.0; indexes 021 match the previous 22-double layout — 013 the original
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
@@ -202,8 +244,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// parked-AU overflow subset of the window's `skipped` at 19 (decoder fell behind, vs benign
/// newest-wins pacing); 33/34 are the AUDIO plane's latency — the playback ring's live depth in ms
/// and the A/V sync loop's smoothed offset in ms (positive = audio behind the picture) — both live
/// gauges rather than windowed samples, like the cumulative drop total at 9), or `null` when no
/// decode thread is running.
/// gauges rather than windowed samples, like the cumulative drop total at 9; 3537 are the audio
/// FORMAT the host resolved at the handshake — `audioCodec` (`0` = Opus on `0xC9`, `2` = lossless
/// PCM on `0xD3`), the resolved rate in Hz and the resolved depth in bits. Static for the session,
/// and here because `design/hi-res-audio.md` §10 requires a surface for the RESOLVED format rather
/// than the requested one: a session that spends 4.6 Mbps and a session whose host quietly
/// declined look identical from the outside, which is §4.3's failure wearing a UI hat), or `null`
/// when no decode thread is running.
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
@@ -227,7 +274,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats<
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
let mode = h.client.mode();
let color = h.client.color;
let buf: [f64; 35] = [
let buf: [f64; 38] = [
snap.fps,
snap.mbps,
snap.e2e_p50_ms,
@@ -290,6 +337,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats<
// high" report had no instrument behind it at all.
h.client.audio_buffer_ms() as f64,
h.client.audio_av_offset_ms() as f64,
// The audio format the host RESOLVED (`Welcome`), not what this device asked for.
// A lossless session and a session whose host declined lossless are indistinguishable
// from the outside — same picture, same latency figures, one of them quietly spending
// 2.34.6 Mbps of the link on nothing — so the HUD has to be able to name which
// (`design/hi-res-audio.md` §10, and §4.3 for why it matters). Static for the session:
// the plane is settled at the handshake and the host never switches it underneath a
// client whose output device is already open.
h.client.audio_codec as f64,
h.client.audio_sample_rate_hz as f64,
h.client.audio_bits as f64,
];
let arr = env.new_double_array(buf.len())?;
arr.set_region(env, 0, &buf)?;
+4
View File
@@ -31,6 +31,10 @@ Opus audio, cert pinning — lives in the shared Rust **`punktfunk-core`** (stat
Keychain-stored identity.
- **Tune the stream** — a fps / Mb·s / **latency** HUD (skew-corrected across machines), a bitrate
control, a per-host **network speed test** with a recommended bitrate, and a host-compositor picker.
- **Send logs to host** — the app keeps its recent log in a bounded in-memory ring (`ClientLog`, a
drop-in for `os.Logger` that also writes the unified log); a host card's menu (or the gamepad
UI's host options) posts it to the paired host's `/api/v1/client-logs`, where the web console's
Logs page shows it next to the host's own — the same action the Gaming Mode console has.
Runs from one shared codebase across **macOS, iOS, iPadOS, and tvOS**.
@@ -54,7 +54,20 @@ struct GamepadAddHostView: View {
@State private var port = "9777"
@State private var focusID: String?
/// The field row the keyboard tray is editing; nil the row list owns the controller.
@State private var editing: String?
@State private var editing: String? = Self.initialEditing
/// Shot harness only: open with a field being edited (`PUNKTFUNK_SHOT_EDITING=address`),
/// so the keyboard tray and the row seated above it can be rendered without a pad.
private static var initialEditing: String? {
#if DEBUG
guard ScreenshotMode.isActive else { return nil }
let field = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_EDITING"] ?? ""
return field.isEmpty ? nil : field
#else
return nil
#endif
}
/// The edited row's flight between its place in the list and its seat above the keyboard.
@Namespace private var fieldFlight
var body: some View {
GamepadMenuList(
@@ -67,6 +80,20 @@ struct GamepadAddHostView: View {
rowView(row, focused: focused)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
// While the tray edits this row, the row IS the one seated above the keyboard
// (see `bottomTray`); its slot here stays empty and keeps the list's layout.
.opacity(editing == row.id ? 0 : 1)
// The flight's origin/destination: an invisible frame-provider that exists only
// while the row is HERE. When editing starts it unmounts and the seated row is
// inserted with the same id, so SwiftUI animates the seated row in FROM this
// frame; when editing ends it returns and the seated row's removal flies back to
// it. Exactly one matched view per id at any time two live ones with the
// source flag swapped sent the invisible list row flying instead.
.overlay {
if editing != row.id {
Color.clear.matchedGeometryEffect(id: row.id, in: fieldFlight)
}
}
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
@@ -131,12 +158,16 @@ struct GamepadAddHostView: View {
// The visible close is gone (a gamepad UI exits with B) this keeps a hardware
// keyboard's Esc and the macOS sheet's cancel working without chrome.
.background {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
// Not while the keyboard tray is up: Esc is the tray's Done then (see
// GamepadKeyboard), and a shortcut here would fire first and close the whole screen.
if editing == nil {
Button("Cancel") { performClose() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.plain)
.frame(width: 0, height: 0)
.opacity(0)
.accessibilityHidden(true)
}
}
#endif
#if os(tvOS)
@@ -173,29 +204,43 @@ struct GamepadAddHostView: View {
#else
if let editing {
VStack(spacing: 10) {
GamepadKeyboard(
text: editingBinding(editing),
allowed: allowedCharacters(editing),
onDone: { closeKeyboard() })
// Fresh keyboard per field: a touch user can retarget the tray by tapping
// another field row, and the keyboard's input wiring captured the previous
// binding on appear new identity forces a rewire to the new field.
.id(editing)
GamepadHintBar(hints: [
// "Type" names what A does to the key under the keyboard's cursor. There is
// no tap equivalent a touch user types by tapping the keycap itself so
// this one cell stays a label.
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
// The field being typed into sits HERE, directly above the keys flown in from
// its place in the list on the tray's spring so what the keyboard covers no
// longer depends on where the list happened to be scrolled. The same row view,
// so it reads as the row itself having come down to the keyboard.
if let row = rows.first(where: { $0.id == editing }) {
rowView(row, focused: true)
.frame(maxWidth: metrics.rowMaxWidth)
.padding(.horizontal, 24)
.matchedGeometryEffect(id: row.id, in: fieldFlight)
.frame(maxWidth: .infinity)
.transition(.opacity)
}
VStack(spacing: 10) {
GamepadKeyboard(
text: editingBinding(editing),
allowed: allowedCharacters(editing),
onDone: { closeKeyboard() })
// Fresh keyboard per field: a touch user can retarget the tray by tapping
// another field row, and the keyboard's input wiring captured the previous
// binding on appear new identity forces a rewire to the new field.
.id(editing)
GamepadHintBar(hints: [
// "Type" names what A does to the key under the keyboard's cursor. There is
// no tap equivalent a touch user types by tapping the keycap itself so
// this one cell stays a label.
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
.init(
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
action: { backspace(editing) }),
.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
action: { closeKeyboard() }),
])
.frame(maxWidth: .infinity, alignment: .leading)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else {
GamepadHintBar(hints: [
.init(
@@ -40,6 +40,10 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// insets center exactly one at a time.
let itemWidth: CGFloat
let spacing: CGFloat
/// The item to open ON when the strip mounts a remembered position (the library's last
/// opened title). Consulted once, before the first `reconcile()`; ignored when it isn't in
/// `items`, in which case the strip opens on the first item as it always has.
var initialItemID: Item.ID?
/// A activate the centered item.
let onActivate: (Item) -> Void
/// Y the screen's secondary action (e.g. open a host's library); nil disables it.
@@ -57,6 +61,9 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
var onUp: (() -> Void)?
/// L1/R1 jump this many items at once (clamped to the ends); 0 disables the shoulders.
var shoulderJump: Int = 0
/// L1 (`false`) / R1 (`true`) the screen's own shoulder action (the Collections screen steps
/// its sort with them). Set, it takes the shoulders away from `shoulderJump`.
var onShoulder: ((Bool) -> Void)?
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
/// otherwise poll the SAME controller at once driving both. The parent sets this false while
@@ -172,9 +179,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
.safeAreaPadding(.horizontal, inset)
.offset(x: bumpOffset)
#if os(tvOS)
// Land initial focus on the first card (the launcher's first host / the coverflow's
// first title) instead of wherever the engine guesses.
.defaultFocus($focusedID, items.first?.id)
// Land initial focus on the remembered card when there is one, else the first
// (the launcher's first host / the coverflow's first title) instead of wherever
// the engine guesses.
.defaultFocus(
$focusedID,
initialItemID.flatMap { index(of: $0) != nil ? $0 : nil } ?? items.first?.id)
// Focus moved (remote swipe / pad dpad) chase it: cursor, detail selection,
// controller detent, and an imperative center scroll.
.onChange(of: focusedID) { _, newValue in
@@ -209,6 +219,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
onBack: onBack)
#endif
.onAppear {
// Seat the cursor on the remembered item before the first reconcile publishes it as
// the scroll target only while nothing has been aligned yet (a re-appear keeps
// wherever the strip already is).
if scrolledID == nil, let id = initialItemID, let idx = index(of: id) {
cursor = idx
}
reconcile()
wire()
if isActive { input.start() }
@@ -288,8 +304,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// landing first and its neighbours fanning outward to either side.
private func entrance(_ idx: Int) -> CardEntrance {
// Capped so a several-hundred-title library never queues a card behind a visibly long
// wait everything past the cap lands together, well off-screen anyway.
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.07)
// wait everything past the cap lands together, well off-screen anyway. The stagger and
// the cap move as a PAIR: their ratio is how many steps actually fan, so a wider offset
// under the same cap would land the outer half of the strip in one block.
// Mirrors `entrances::CARDS` in the desktop console's anim.rs nothing pins the two
// together, so a change here is a change there.
let delay = min(CardEntrance.maxDelay, Double(abs(idx - entranceAnchor)) * 0.12)
return CardEntrance(
progress: entranceProgress,
start: delay / CardEntrance.total,
@@ -325,7 +345,7 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
input.onSecondary = onSecondary
input.onTertiary = onTertiary
input.onBack = onBack
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
input.onShoulder = onShoulder ?? (shoulderJump > 0 ? { shoulder(right: $0) } : nil)
#endif
}
@@ -499,9 +519,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
/// focus engine are untouched either. Reduce Motion drops every bit of travel for a plain,
/// unstaggered cross-fade.
struct CardEntrance: ViewModifier, Animatable {
/// How long ONE card takes to travel, and the most any card waits before it starts.
/// How long ONE card takes to travel, and the most any card waits before it starts. The
/// pair matches `entrances::CARDS` in the desktop console's anim.rs; `maxDelay` divided by
/// the per-step stagger in `entrance(_:)` is the number of cards that visibly fan, which is
/// why it moves whenever that stagger does.
static let perCard: Double = 0.6
static let maxDelay: Double = 0.42
static let maxDelay: Double = 0.6
/// The master timeline the carousel animates 0 1.
static var total: Double { perCard + maxDelay }
@@ -162,14 +162,28 @@ struct GamepadHomeView: View {
.geometryGroup()
.zIndex(1)
.id(screen.id)
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
// Reduce Motion: a crossfade no slide, no scale (the desktop's rule).
.transition(
reduceMotion
? .opacity
: .gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
}
// Back mid-push (the desktop's interruptible transition): while a push is in flight
// no layer owns the controller, so this zero-size listener takes B alone and turns
// the entering screen around. A and the rest stay dropped until the spring has
// passed 0.85 of its travel (`transitioning`), which keeps a double-tapped A from
// pushing two screens.
if transitioning, topScreen != nil {
MidPushBackCatcher { backOutOfPush() }
}
#endif
}
// Value-keyed rather than `withAnimation` at the triggers: pushes originate outside
// this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the
// derived id catches every writer. Reduce Motion snaps.
.animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID)
// derived id catches every writer. Reduce Motion crossfades on the reduced spring.
.animation(
reduceMotion ? GamepadShellMotion.reducedScreen : GamepadShellMotion.screen,
value: topScreenID)
// ONE living field for every layer, still a `.background` (the layout rule in this
// file's header). Its calm is CHASED between the launcher's aurora and the form
// screens' quiet, never crossfaded per screen the console's `bg_mix`.
@@ -195,7 +209,9 @@ struct GamepadHomeView: View {
transitionEpoch += 1
let epoch = transitionEpoch
transitioning = true
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
// The gate opens when the spring has passed 0.85 of its travel, not when it has
// settled the wall is gone, the double-tap protection stays.
let hold = reduceMotion ? 0.05 : GamepadShellMotion.inputOpensAfter
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
if epoch == transitionEpoch { transitioning = false }
}
@@ -348,6 +364,24 @@ struct GamepadHomeView: View {
#endif
}
#if os(iOS)
/// Back pressed while a push is still in flight: clear the trigger that raised the top
/// screen, so the same spring carries it back down the desktop retargets its NAV spring
/// to 0 mid-push; SwiftUI does the equivalent when the identity flips back before the
/// insertion has settled.
private func backOutOfPush() {
guard transitioning, let screen = topScreen else { return }
switch screen {
case .pair: pairingTarget = nil
case .editHost: editTarget = nil
case .hostOptions: hostOptionsTarget = nil
case .settings: showSettings = false
case .addHost: showAddHost = false
case .library: libraryTarget = nil
}
}
#endif
private var topScreenID: String? {
#if os(iOS)
topScreen?.id
@@ -620,6 +654,7 @@ struct GamepadHomeView: View {
guard let profile = target.profile else { return }
store.setPinned(host.id, profileID: profile.id, pinned: false)
},
onSendLogs: host.pinnedSHA256 != nil ? { await SendLogs.toHost(host) } : nil,
close: { if !transitioning { hostOptionsTarget = nil } },
controllerActive: active)
}
@@ -808,3 +843,23 @@ private struct GamepadHostTile: View {
}
}
#endif
#if os(iOS)
/// Zero-size controller listener for a push in flight B alone. The same shape as LibraryView's
/// `LibraryBackCatcher`; `GamepadMenuInput.needsSnapshot` swallows the still-held A that pushed
/// the screen, so only a FRESH B mid-push counts. Unmounts the moment the gate opens.
private struct MidPushBackCatcher: View {
let onBack: () -> Void
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.onAppear {
input.onBack = onBack
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
@@ -64,6 +64,9 @@ struct GamepadHostOptionsView: View {
/// Delete the saved record outright.
let onRemove: () -> Void
let onUnpin: () -> Void
/// Upload this device's recent log to the host; answers with what to tell the user. nil on an
/// unpaired host the upload rides the pairing, so there is nothing to offer before it.
var onSendLogs: (() async -> (ok: Bool, message: String))?
var close: (() -> Void)?
var controllerActive = true
@@ -81,13 +84,21 @@ struct GamepadHostOptionsView: View {
/// strict as it is, and none at all to be looser.
@State private var armed = false
@State private var copied = false
/// The send-logs row's own state: its label and the detail band report the outcome in place,
/// the same way Copy link says "Copied" this surface has no toast.
@State private var sendLogs: SendLogsState = .idle
@State private var focusID: String?
private enum SendLogsState: Equatable {
case idle, sending, done(ok: Bool, message: String)
}
private enum Action: String {
case wake
case copyLink
case edit
case forgetPairing
case sendLogs
case remove
case unpin
case cancel
@@ -195,6 +206,15 @@ struct GamepadHostOptionsView: View {
}
list.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
list.append(Row(action: .edit, label: "Edit\u{2026}", icon: "pencil"))
if onSendLogs != nil {
let label: String
switch sendLogs {
case .idle: label = "Send logs to host"
case .sending: label = "Sending logs\u{2026}"
case .done(let ok, _): label = ok ? "Logs sent" : "Couldn't send logs"
}
list.append(Row(action: .sendLogs, label: label, icon: "doc.text"))
}
// Only a paired host has a pairing to drop.
if host.pinnedSHA256 != nil {
list.append(Row(
@@ -221,6 +241,9 @@ struct GamepadHostOptionsView: View {
case .forgetPairing:
return "Drop the stored fingerprint. The host stays saved and the next connect "
+ "pairs again."
case .sendLogs:
if case .done(_, let message) = sendLogs { return message }
return "Upload this device's recent log to the host, for its web console's Logs page."
case .remove:
return armed
? "Press again to remove — this cannot be undone."
@@ -263,6 +286,15 @@ struct GamepadHostOptionsView: View {
case .forgetPairing:
onForgetPairing()
performClose()
case .sendLogs:
guard let onSendLogs, sendLogs != .sending else { return }
withAnimation(.smooth(duration: 0.2)) { sendLogs = .sending }
Task {
let outcome = await onSendLogs()
withAnimation(.smooth(duration: 0.2)) {
sendLogs = .done(ok: outcome.ok, message: outcome.message)
}
}
case .remove:
guard armed else {
withAnimation(.smooth(duration: 0.2)) { armed = true }
@@ -7,7 +7,10 @@
//
// Edits are applied to the binding live (the caller's field row shows every keystroke), so
// closing the keyboard is always "done" there is no separate cancel/commit step to get wrong.
// Touch stays a fallback: every keycap is tappable.
// Touch stays a fallback: every keycap is tappable. A HARDWARE keyboard (an iPad on a Magic
// Keyboard, a Mac) types straight into the field while the tray is up characters insert,
// deletes, arrows move the key cursor, Return and Esc are Done with no system keyboard ever
// raised, because there is no text field to raise it.
import PunktfunkKit
import SwiftUI
@@ -26,6 +29,9 @@ struct GamepadKeyboard: View {
@State private var cursor = GridPos(row: 1, col: 0) // opens on "q"
@State private var pressTick = 0
@State private var boundaryTick = 0
/// Hardware-keyboard focus: the tray takes it on appear so key presses land here (the row
/// list behind it hands its own key navigation off while editing).
@FocusState private var hardwareFocus: Bool
#if os(iOS)
/// `.compact` (landscape phone): shorter keycaps so the tray leaves room for the field rows.
@Environment(\.verticalSizeClass) private var vSizeClass
@@ -85,9 +91,15 @@ struct GamepadKeyboard: View {
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
// Hardware keys. No focus ring the tray draws its own cursor.
.focusable()
.focusEffectDisabled()
.focused($hardwareFocus)
.onKeyPress(phases: .down) { hardwareKey($0) }
.onAppear {
wire()
input.start()
hardwareFocus = true
}
.onDisappear {
input.stop()
@@ -181,5 +193,55 @@ struct GamepadKeyboard: View {
boundaryTick &+= 1
haptics.boundary()
}
/// A hardware key while the tray is up. Return and Esc are both Done the binding already
/// holds the text, so there is nothing to cancel; deletes; arrows drive the key cursor
/// (Return does NOT type the highlighted key a keyboard user types letters, not caps);
/// anything with (Q, W) is left to the system.
private func hardwareKey(_ key: KeyPress) -> KeyPress.Result {
if key.modifiers.contains(.command) { return .ignored }
switch key.key {
case .escape, .return:
haptics.confirm()
onDone()
return .handled
case .delete, .deleteForward:
DispatchQueue.main.async { press(.backspace) }
return .handled
case .leftArrow: move(.left); return .handled
case .rightArrow: move(.right); return .handled
case .upArrow: move(.up); return .handled
case .downArrow: move(.down); return .handled
case .space:
DispatchQueue.main.async { press(.space) }
return .handled
default:
break
}
// can also arrive as a bare control character rather than a named key.
if key.characters == "\u{7F}" || key.characters == "\u{08}" {
DispatchQueue.main.async { press(.backspace) }
return .handled
}
// Printable characters including ones the on-screen grid doesn't offer (a capital,
// an umlaut): the on-screen set is deliberately small, the field's own `allowed` set is
// the real rule.
let typed = key.characters
guard !typed.isEmpty,
typed.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) })
else { return .ignored }
if let allowed, !typed.unicodeScalars.allSatisfy(allowed.contains) {
refuse()
return .handled
}
// Off the key-event dispatch: writing the binding synchronously here re-rendered the
// screen mid-delivery and a fast burst of keystrokes lost every other one.
DispatchQueue.main.async {
text.append(typed)
pressTick &+= 1
haptics.move()
}
return .handled
}
}
#endif
@@ -26,15 +26,24 @@ struct GamepadLibraryScreen: View {
@ObservedObject private var profiles = ProfileStore.shared
private var compact: Bool { vSizeClass == .compact }
/// The collection the shelf is drilled into, for the title `host · profile · collection`.
@State private var collection: String?
private var title: String {
let base = target.title(in: profiles)
guard let collection else { return "\(base) — Library" }
return "\(base) \u{b7} \(collection) — Library"
}
var body: some View {
LibraryView(
store: store, target: target, onLaunch: onLaunch,
onClose: close, controllerActive: controllerActive)
onClose: close, controllerActive: controllerActive,
onCollectionChanged: { collection = $0 })
.safeAreaInset(edge: .top, spacing: 0) {
// Leading, like every gamepad heading no close chrome, B is the exit (the
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
Text("\(target.title(in: profiles)) — Library")
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
@@ -51,21 +51,31 @@ enum GamepadScreen: Identifiable {
}
}
/// The console shell's motion constants, mapped to SwiftUI. Source of truth:
/// `crates/pf-console-ui/src/shell/render.rs` (push/pop) and `shell.rs` (`TRANSITION_S`).
/// The console shell's motion, mapped to SwiftUI from the numbers `ConsoleMotion` pins against
/// the shared vectors' `motion_spring` block (version 2). Source of truth:
/// `crates/pf-console-ui/src/shell.rs` (the NAV spring) and `shell/render.rs` (the geometry).
enum GamepadShellMotion {
/// One transition, both layers the console's `TRANSITION_S`.
static let duration: TimeInterval = 0.26
/// `1-(1-t)³` as a bezier: the standard ease-out-cubic control points.
static let screen = Animation.timingCurve(0.33, 1, 0.68, 1, duration: duration)
/// One transition, both layers the console's `springs::NAV`. A spring rather than the old
/// 0.26 s ease-out-cubic, so a Back pressed mid-push turns the entering screen around instead
/// of waiting for a tween to finish (`ConsoleMotion.interruptible`).
static let screen = Animation.spring(
response: ConsoleMotion.response, dampingFraction: ConsoleMotion.damping)
/// Reduce Motion: a plain crossfade on the desktop's `REDUCED_NAV` no slide, no scale.
static let reducedScreen = Animation.spring(
response: ConsoleMotion.reducedResponse, dampingFraction: ConsoleMotion.reducedDamping)
/// The backdrop's calm chase. The console runs an exponential approach (τ 0.12 s); the same
/// ease-out at 0.30 s lands within a few percent of it and settles together with the screen.
static let calm = Animation.timingCurve(0.33, 1, 0.68, 1, duration: 0.30)
/// The push/pop travel the console's `36 * k`, k-floored for a landscape phone.
static func slide(compact: Bool) -> CGFloat { compact ? 27 : 36 }
static func slide(compact: Bool) -> CGFloat {
compact ? 27 : CGFloat(ConsoleMotion.pushSlideDp)
}
/// The incoming screen grows from this; the revealed launcher grows back from `underScale`.
static let inScale: CGFloat = 0.985
static let underScale: CGFloat = 0.96
static let inScale = CGFloat(ConsoleMotion.enterScale)
static let underScale = CGFloat(ConsoleMotion.exitScale)
/// When a fresh push starts accepting input other than Back (the desktop's
/// `NAV_INPUT_OPENS` 0.85 of the spring's travel, as a time).
static let inputOpensAfter: TimeInterval = ConsoleMotion.inputOpensAfter
}
extension AnyTransition {
@@ -45,6 +45,8 @@ struct HomeView: View {
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost?
/// The outcome of the last "Send Logs to Host" drives its alert.
@State private var sendLogsResult: (ok: Bool, message: String)?
// How this device shows its own list. `.added` is the default because it is what the grid
// did before it could sort at all an update should not rearrange anyone's hosts.
@AppStorage(DefaultsKey.hostSort) private var sortRaw = HostSort.added.rawValue
@@ -194,6 +196,16 @@ struct HomeView: View {
}
#endif
}
.alert(
sendLogsResult?.ok == true ? "Logs Sent" : "Couldn't Send Logs",
isPresented: Binding(
get: { sendLogsResult != nil },
set: { if !$0 { sendLogsResult = nil } })
) {
Button("OK", role: .cancel) {}
} message: {
Text(sendLogsResult?.message ?? "")
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 360)
#endif
@@ -292,6 +304,8 @@ struct HomeView: View {
onBrowseLibrary: onBrowseLibrary,
onWake: { wake(host) },
onEdit: { editTarget = host },
onSendLogs: host.pinnedSHA256 != nil
? { Task { sendLogsResult = await SendLogs.toHost(host) } } : nil,
profileMenu: profileMenu(for: host),
pinnedProfile: pinned)
}
@@ -126,13 +126,20 @@ struct HostCardView: View {
let onSpeedTest: () -> Void
let onForget: () -> Void
let onRemove: () -> Void
/// Open the experimental library browser nil (no menu item) unless the feature flag is on.
/// Open this host's game library a MENU action. `nil` no library affordance at all when
/// the setting is off or the host is unpaired (the library plane needs the pinned identity).
///
/// Never the card's primary tap: tapping a host connects to it, on every surface. Browsing is
/// one step further in, exactly where the console shell keeps it (Y on a tile).
var onBrowseLibrary: (() -> Void)? = nil
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
var onWake: (() -> Void)? = nil
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
var onEdit: (() -> Void)? = nil
/// Upload this device's recent log to the host (`SendLogs`). `nil` when the host is unpaired
/// the upload is authenticated by the pairing, so there is nothing to offer before it.
var onSendLogs: (() -> Void)? = nil
/// This card's profile affordances nil on surfaces that don't offer them.
var profileMenu: HostProfileMenu? = nil
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
@@ -248,6 +255,9 @@ struct HostCardView: View {
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if let onSendLogs {
Button("Send Logs to Host", action: onSendLogs)
}
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
Button("Wake Host", systemImage: "power", action: onWake)
}

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