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
3 Commits
Author SHA1 Message Date
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
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
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"
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
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 9e561f1404 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
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