The library round-trip reaches the other clients — C7 for Android and the console UI #278

Merged
enricobuehler merged 3 commits from worktree-library-parity-c7 into main 2026-08-17 08:55:27 +00:00
Owner

PR #276 shipped six library behaviours on the Apple client only, and deferred parity (C7) to a follow-up. This is that follow-up for Android and the Linux/console-UI shell. webOS rides in its own repo — see the note at the bottom.

No host work: GET /api/v1/status already carries games[] and is already on the paired-cert allowlist (mgmt::auth::cert_may_access), the same mTLS identity the catalog fetch uses.

⚠️ Item 1 was implemented and then reverted at review — see the bottom section. No input mapping changes anywhere in this PR. A host card's primary press still connects; the library stays where it already was (Y on the carousels, "Browse library…" in Android's touch overflow menu).

The five behaviours this PR actually ships

Judged per item and per client rather than ported — two of them meant something different away from SwiftUI.

2. 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 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 claims something is happening while nothing is must not be possible to write here.

3. The host is woken on library ENTRY, and the fetch retries across the boot window (12 × 5 s, 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.

4. Titles already up are badged Resume, read from /status. untracked and grace both count as up; only a confirmed exited does not.

5. 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.

6. 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

  • 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.
  • Forgetting a host drops its cached catalog (and, on Android, its remembered position): a removed host leaves no list of what somebody plays behind on disk.

Item 1 — implemented, then reverted (9a64a0fa)

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

Backed out at review. The two gamepad halves were an extrapolation — those shells have no "tap" — and 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, but it goes back too rather than leaving Android's own two shells disagreeing about what a host card's primary press does.

screens/home.rs, GamepadHome.kt and ConnectGrid.kt are now byte-identical to main. shell/tests.rs differs from base by exactly the seven running: false lines item 4/5 needs — the eight tests that encode the A/Y mapping are back to asserting what ships.

Verified

  • console-UI — 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), 175 + 219 tests green (175 = the pre-existing count, restored by the revert). Non-vacuity confirmed.
  • Android:kit: and :app: compileDebugKotlin and compileDebugUnitTestKotlin green (the screenshot harness composes the real grid and coverflow, so their new parameters are defaulted and it still builds).
  • Both lanes re-run over the reverted tree, not just the original one.
  • New unit tests: 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, running-first preserving the launcher prefix, and set_running being a no-op — no generation bump, so no re-sync — when nothing changed.

webOS — separate repo, NOT in this PR

The webOS half is written and gated green (cargo clippy --target armv7-unknown-linux-gnueabi --all-targets -- -D warnings, in the project's own Docker toolchain), but it lives in punktfunk-webos and this session could not commit there. Item 1 never applied to it — 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. Item 3 was already satisfied too (start_wake already probes and retries). Items 2, 4, 5 and 6 are new. One judgement worth flagging: webOS has user-pinned games, so running-first leads the unpinned remainder only — an explicit pin is a placement the player chose and should not be overruled by a transient state.

Files touched there: src/{main,library,library_cache,store}.rs, src/app/{mod,home,addhost,edithost,pairing}.rs, src/ui/{cards,tiles}.rs.

PR #276 shipped six library behaviours on the **Apple client only**, and deferred parity (C7) to a follow-up. This is that follow-up for **Android** and the **Linux/console-UI shell**. webOS rides in its own repo — see the note at the bottom. No host work: `GET /api/v1/status` already carries `games[]` and is already on the paired-cert allowlist (`mgmt::auth::cert_may_access`), the same mTLS identity the catalog fetch uses. > **⚠️ Item 1 was implemented and then reverted at review — see the bottom section.** No input mapping changes anywhere in this PR. A host card's primary press still connects; the library stays where it already was (Y on the carousels, "Browse library…" in Android's touch overflow menu). ## The five behaviours this PR actually ships Judged per item and per client rather than ported — two of them meant something different away from SwiftUI. **2. 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 **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 claims something is happening while nothing is must not be *possible* to write here. **3. The host is woken on library ENTRY**, and the fetch retries across the boot window (12 × 5 s, 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. **4. Titles already up are badged Resume**, read from `/status`. `untracked` and `grace` both count as up; only a confirmed `exited` does not. **5. 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. **6. 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 - `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. - Forgetting a host drops its cached catalog (and, on Android, its remembered position): a removed host leaves no list of what somebody plays behind on disk. ## Item 1 — implemented, then reverted (`9a64a0fa`) #276's item 1 read *"tapping a paired host opens its LIBRARY (streaming the desktop becomes the secondary action)"*. `9e561f14` applied that to three surfaces: Android's touch host card (tap → library, "Stream the desktop" into the overflow, mirroring Apple's `HostCards.swift`), Android's gamepad carousel, and the console-UI carousel (both: A → library, Y → desktop). Backed out at review. The two gamepad halves were an extrapolation — those shells have no "tap" — and 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, but it goes back too rather than leaving Android's own two shells disagreeing about what a host card's primary press does. `screens/home.rs`, `GamepadHome.kt` and `ConnectGrid.kt` are now **byte-identical to `main`**. `shell/tests.rs` differs from base by exactly the seven `running: false` lines item 4/5 needs — the eight tests that encode the A/Y mapping are back to asserting what ships. ## Verified - **console-UI** — 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`), **175 + 219 tests green** (175 = the pre-existing count, restored by the revert). Non-vacuity confirmed. - **Android** — `:kit:` and `:app:` `compileDebugKotlin` **and** `compileDebugUnitTestKotlin` green (the screenshot harness composes the real grid and coverflow, so their new parameters are defaulted and it still builds). - Both lanes re-run over the reverted tree, not just the original one. - New unit tests: 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, running-first preserving the launcher prefix, and `set_running` being a no-op — no generation bump, so no re-sync — when nothing changed. ## webOS — separate repo, NOT in this PR The webOS half is written and **gated green** (`cargo clippy --target armv7-unknown-linux-gnueabi --all-targets -- -D warnings`, in the project's own Docker toolchain), but it lives in `punktfunk-webos` and this session could not commit there. Item 1 never applied to it — 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. Item 3 was already satisfied too (`start_wake` already probes and retries). Items 2, 4, 5 and 6 are new. One judgement worth flagging: webOS has **user-pinned games**, so running-first leads the *unpinned remainder* only — an explicit pin is a placement the player chose and should not be overruled by a transient state. Files touched there: `src/{main,library,library_cache,store}.rs`, `src/app/{mod,home,addhost,edithost,pairing}.rs`, `src/ui/{cards,tiles}.rs`.
enricobuehler added 1 commit 2026-08-17 00:09:46 +00:00
feat(clients/android,console): the library round-trip reaches the other clients — C7
ci / docs-site (pull_request) Successful in 1m18s
ci / bun-nix (pull_request) Successful in 3m38s
ci / rust-arm64 (pull_request) Successful in 4m8s
ci / web (pull_request) Successful in 4m17s
android / android (pull_request) Successful in 4m58s
ci / rust (pull_request) Successful in 5m8s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m31s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m1s
9e561f1404
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.
enricobuehler added 1 commit 2026-08-17 07:57:06 +00:00
revert(clients/android,console): item 1 — a host card's primary press stays "connect"
android / android (pull_request) Successful in 6m5s
ci / rust (pull_request) Successful in 6m13s
ci / web (pull_request) Successful in 1m12s
ci / docs-site (pull_request) Successful in 1m21s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m30s
ci / bun-nix (pull_request) Successful in 4m23s
ci / rust-arm64 (pull_request) Successful in 7m5s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m16s
9a64a0faef
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`.
enricobuehler added 1 commit 2026-08-17 08:34:15 +00:00
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
ci / docs-site (pull_request) Successful in 1m19s
ci / rust-arm64 (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 1m33s
ci / web (pull_request) Successful in 3m37s
ci / rust (pull_request) Successful in 4m53s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m41s
android / android (pull_request) Successful in 8m54s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 2m52s
b93815ac6e
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.
enricobuehler merged commit 6a049b490b into main 2026-08-17 08:55:27 +00:00
enricobuehler deleted branch worktree-library-parity-c7 2026-08-17 08:55:33 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#278