Launcher tiles reach the clients, and Playnite can publish again #70

Merged
enricobuehler merged 5 commits from worktree-library-clients into main 2026-08-06 13:03:45 +00:00
Owner

The bridge half (#59) gave the host everything it needs to describe a launcher entry — role: "launcher", the steam_ui and launcher_ui kinds — and gave the web console a Launchers rail. Nothing else ever looked, and no plugin ever emitted one. This is the other half.

1. A launcher tile looks like one, on every client (883c3178)

pf-client-core decoded role into an is_launcher() helper with zero call sites, and the shared console model dropped the field on its way to the renderer. So a launcher tile arrived everywhere but the web console as an ordinary game with no cover art: indistinguishable from a title whose poster failed to load, sorted into the middle of the alphabet, captioned "Play".

One contract, in each client's own idiom:

  • launchers never interleave with titles — they lead, and each group keeps the host's title order;
  • grid surfaces get a labelled section; a coverflow keeps its single carousel and names the group the cursor is in, changing as it crosses the boundary. A second focus rail would mean a new up/down nav model in three renderers for two or three tiles;
  • an art-less launcher gets an accent face naming its launcher, not a title monogram on the neutral one — "opens Steam", not "a cover that didn't load";
  • anything that is not "launcher" is a game, and a host that omits the field renders exactly as before (D4's intended degradation);
  • launching is unchanged — the client sends an id, the host resolves the recipe.

The grouping is enforced once per client stack, not per screen: it is an invariant of LibraryShared::set_games for the console UI (so cursor arithmetic, the art pump and every future consumer inherit it), and applied at fetch/parse on Apple and Android.

Covers: the console/gamepad coverflow, Apple's grid and coverflow, Android, the GTK client, the Windows client, and --library on the CLI (a fourth column, appended so anything reading the first three is untouched).

Fixed in passing: the Apple and Android store badges were hard-coded isCustom ? "Custom" : "Steam" — every Lutris, GOG, Heroic, Epic and Xbox title was labelled "Steam". Both now carry the same store table the Rust clients use.

2. Playnite can publish again, and gets a fullscreen tile (8ff2c2e1)

The published @punktfunk/plugin-playnite@0.3.0 cannot publish anything on this host. It emits kind: "command" for every game, the 2026-08-05 review made command operator-only, and privileged_field refuses a provider reconcile carrying one — so the whole payload is rejected. Not one game, and no launcher tile either. That is a live regression against a shipped plugin, and it is the same hole launcher_ui was created to close, one kind further along.

Two host-owned kinds, so D1 holds (the plugin supplies a validated value, never a command line):

kind value resolves to
playnite the game's GUID explorer.exe "playnite://playnite/start/<guid>" — the same protocol-via-a-concrete-EXE shape the epic kind uses. GUID-validated inbound and at launch.
launcher_ui "playnite" (Windows) Playnite.FullscreenApp.exe, with Playnite's install dir as the working directory.

Fullscreen rather than Desktop is the point of a couch tile — and it is also why this one cannot ride the URI the games use: probed on .173, Playnite's registered playnite:// handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen mode. The install dir comes from Playnite's own uninstall entry (HKCU, then HKLM), falling back to %LOCALAPPDATA%\Playnite.

valid_launcher_ui("playnite") is answered by resolution rather than a static list: a host without Playnite refuses the entry instead of publishing a tile that does nothing when clicked. Same instinct that leaves Epic, GOG Galaxy and the Xbox app off — each still needs its own verified activation, and a guess would ship exactly that dead tile.

3. Two pre-existing failures found while gating on Windows

Neither is from this branch; both are invisible to Linux CI, which never compiles those arms. Each is its own commit so they can be dropped independently.

  • dd20a17elibrary::art tests built format!("file://{path}"), which on Windows is file://C:\…: authority C:, i.e. a UNC reference. The parser is right (the kit emits file:///C:/…) and the tests were wrong; they now build the value the way @punktfunk/plugin-kit/library's fileUrl does. Test-only — and that file is the file:// art contract every extracted library plugin depends on.
  • 58ee74cbcargo clippy -p punktfunk-client-windows -- -D warnings fails on main with the pinned 1.96.0 toolchain (manual_is_multiple_of in connect.rs). Clippy's own suggestion, applied verbatim.

Gates

Gate Where Result
cargo test -p punktfunk-host .21 437 passed / 0 failed
cargo test -p punktfunk-host --bin punktfunk-host library:: .173 54 passed / 0 failed (the Windows-only arms)
cargo test -p pf-console-ui .21 49 passed / 0 failed
cargo check session + GTK clients .21 clean
cargo clippy -D warnings (4 client crates) .21 clean
cargo clippy -D warnings Windows client .173 clean
cargo fmt --all --check .21 clean
swift build (full PunktfunkClient) macOS Build complete
:app:compileDebugKotlin macOS BUILD SUCCESSFUL

Six new tests: the launcher-first grouping invariant and its no-op case, a launcher entry surviving reconcile onto the wire, and the Playnite launch kinds.

All re-run after merging current origin/main (29 commits, clean auto-merge — main's gamepad-UI palette work and the library-return flow touch the same files).

Not in this PR

  • No launcher tile has been clicked on real hardware. Everything here is compile- and test-verified only; the on-glass run needs the plugins published.
  • WP-B1, lease semantics — a launcher tile has no game process, and gamelease treats "launched thing exited immediately" as not a session end. For a launcher tile the session should live exactly as long as its window. Needs deciding and pinning with a test before any on-glass run. No live exposure yet: nothing publishes tiles until the plugins ship.
  • Plugin-side changes (lutris + heroic implementing the kit's launchers() hook, playnite's typed kind + tile + a plugin-kit 0.2.0 → 0.3.0 bump) live in their own repos and are not committed yet.
  • cargo clippy -p punktfunk-host -- -D warnings still fails on Windows (undocumented_unsafe_blocks, windows/install.rs:169) — pre-existing, left alone.
The bridge half (#59) gave the host everything it needs to describe a launcher entry — `role: "launcher"`, the `steam_ui` and `launcher_ui` kinds — and gave the web console a Launchers rail. Nothing else ever looked, and no plugin ever emitted one. This is the other half. ## 1. A launcher tile looks like one, on every client (`883c3178`) `pf-client-core` decoded `role` into an `is_launcher()` helper with **zero call sites**, and the shared console model dropped the field on its way to the renderer. So a launcher tile arrived everywhere but the web console as an ordinary game with no cover art: indistinguishable from a title whose poster failed to load, sorted into the middle of the alphabet, captioned "Play". One contract, in each client's own idiom: * launchers never interleave with titles — they lead, and each group keeps the host's title order; * grid surfaces get a labelled section; a coverflow keeps its single carousel and names the group the cursor is in, changing as it crosses the boundary. A second focus rail would mean a new up/down nav model in three renderers for two or three tiles; * an art-less launcher gets an accent face **naming its launcher**, not a title monogram on the neutral one — "opens Steam", not "a cover that didn't load"; * anything that is not `"launcher"` is a game, and a host that omits the field renders exactly as before (D4's intended degradation); * launching is unchanged — the client sends an id, the host resolves the recipe. The grouping is enforced **once per client stack**, not per screen: it is an invariant of `LibraryShared::set_games` for the console UI (so cursor arithmetic, the art pump and every future consumer inherit it), and applied at fetch/parse on Apple and Android. Covers: the console/gamepad coverflow, Apple's grid **and** coverflow, Android, the GTK client, the Windows client, and `--library` on the CLI (a fourth column, appended so anything reading the first three is untouched). **Fixed in passing:** the Apple and Android store badges were hard-coded `isCustom ? "Custom" : "Steam"` — every Lutris, GOG, Heroic, Epic and Xbox title was labelled **"Steam"**. Both now carry the same store table the Rust clients use. ## 2. Playnite can publish again, and gets a fullscreen tile (`8ff2c2e1`) **The published `@punktfunk/plugin-playnite@0.3.0` cannot publish anything on this host.** It emits `kind: "command"` for every game, the 2026-08-05 review made `command` operator-only, and `privileged_field` refuses a *provider* reconcile carrying one — so the whole payload is rejected. Not one game, and no launcher tile either. That is a live regression against a shipped plugin, and it is the same hole `launcher_ui` was created to close, one kind further along. Two host-owned kinds, so D1 holds (the plugin supplies a validated **value**, never a command line): | kind | value | resolves to | |---|---|---| | `playnite` | the game's GUID | `explorer.exe "playnite://playnite/start/<guid>"` — the same protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated inbound *and* at launch. | | `launcher_ui` | `"playnite"` (Windows) | `Playnite.FullscreenApp.exe`, with Playnite's install dir as the working directory. | Fullscreen rather than Desktop is the point of a couch tile — and it is *also* why this one cannot ride the URI the games use: probed on `.173`, Playnite's registered `playnite://` handler is bound to `Playnite.DesktopApp.exe`, so no URI opens fullscreen mode. The install dir comes from Playnite's own uninstall entry (HKCU, then HKLM), falling back to `%LOCALAPPDATA%\Playnite`. `valid_launcher_ui("playnite")` is answered by **resolution** rather than a static list: a host without Playnite refuses the entry instead of publishing a tile that does nothing when clicked. Same instinct that leaves Epic, GOG Galaxy and the Xbox app off — each still needs its own verified activation, and a guess would ship exactly that dead tile. ## 3. Two pre-existing failures found while gating on Windows Neither is from this branch; both are invisible to Linux CI, which never compiles those arms. Each is its own commit so they can be dropped independently. * `dd20a17e` — `library::art` tests built `format!("file://{path}")`, which on Windows is `file://C:\…`: authority `C:`, i.e. a UNC reference. The parser is right (the kit emits `file:///C:/…`) and the tests were wrong; they now build the value the way `@punktfunk/plugin-kit/library`'s `fileUrl` does. **Test-only** — and that file is the `file://` art contract every extracted library plugin depends on. * `58ee74cb` — `cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the pinned 1.96.0 toolchain (`manual_is_multiple_of` in `connect.rs`). Clippy's own suggestion, applied verbatim. ## Gates | Gate | Where | Result | |---|---|---| | `cargo test -p punktfunk-host` | `.21` | **437 passed / 0 failed** | | `cargo test -p punktfunk-host --bin punktfunk-host library::` | `.173` | **54 passed / 0 failed** (the Windows-only arms) | | `cargo test -p pf-console-ui` | `.21` | 49 passed / 0 failed | | `cargo check` session + GTK clients | `.21` | clean | | `cargo clippy -D warnings` (4 client crates) | `.21` | clean | | `cargo clippy -D warnings` Windows client | `.173` | clean | | `cargo fmt --all --check` | `.21` | clean | | `swift build` (full `PunktfunkClient`) | macOS | Build complete | | `:app:compileDebugKotlin` | macOS | BUILD SUCCESSFUL | Six new tests: the launcher-first grouping invariant and its no-op case, a launcher entry surviving reconcile onto the wire, and the Playnite launch kinds. All re-run **after** merging current `origin/main` (29 commits, clean auto-merge — main's gamepad-UI palette work and the library-return flow touch the same files). ## Not in this PR * **No launcher tile has been clicked on real hardware.** Everything here is compile- and test-verified only; the on-glass run needs the plugins published. * **WP-B1, lease semantics** — a launcher tile has no game process, and `gamelease` treats "launched thing exited immediately" as *not* a session end. For a launcher tile the session should live exactly as long as its window. Needs deciding and pinning with a test before any on-glass run. No live exposure yet: nothing publishes tiles until the plugins ship. * Plugin-side changes (lutris + heroic implementing the kit's `launchers()` hook, playnite's typed kind + tile + a plugin-kit 0.2.0 → 0.3.0 bump) live in their own repos and are **not** committed yet. * `cargo clippy -p punktfunk-host -- -D warnings` still fails on Windows (`undocumented_unsafe_blocks`, `windows/install.rs:169`) — pre-existing, left alone.
enricobuehler added 5 commits 2026-08-06 12:52:18 +00:00
The host has been able to describe a launcher entry since M2 — `role: "launcher"`,
the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into
their own rail since M4. No other client ever looked. `pf-client-core` decoded
`role` into an `is_launcher()` helper with zero call sites, and the shared console
model dropped the field entirely on its way to the renderer.

So a launcher tile arrived everywhere else as an ordinary game with no cover art:
indistinguishable from a title whose poster failed to load, sorted into the middle
of the alphabet, and captioned "Play".

One contract, implemented in each client's own idiom:

  * launchers never interleave with titles — they lead, and each group keeps the
    host's title order
  * grid surfaces get a labelled section; a coverflow keeps its single carousel and
    names the group the cursor is in, changing as it crosses the boundary. A second
    focus rail would mean a new up/down nav model in three renderers for two or
    three tiles
  * an art-less launcher gets an accent face naming its launcher, not a title
    monogram on the neutral one — "opens Steam", not "a cover that didn't load"
  * anything that is not `"launcher"` is a game, and a host that omits the field
    renders exactly as before (design D4's intended degradation)
  * launching is unchanged: the client sends an id, the host resolves the recipe

The grouping is enforced once per client stack rather than per screen. In the
console UI it is an invariant of `LibraryShared::set_games`, so the cursor
arithmetic, the art pump and every future consumer inherit it; on Apple and Android
it is applied where the library is fetched/parsed.

Fixed in passing: the Apple and Android store badges were hard-coded
`isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title
was labelled "Steam". Both now carry the same store table the Rust clients use.

The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather
than folded into an existing one so anything reading the first three is untouched.

Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed
on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean
there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean
on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173.

Still unproven on hardware: no launcher tile has been clicked on a real host — that
needs the plugins published, which needs this branch's base merged first.
The Playnite plugin emits `kind: "command"` for every game (a `start "" "playnite://…"`
shell line). The 2026-08-05 review made `command` operator-only, and `privileged_field`
refuses a PROVIDER reconcile carrying one — so on this branch the published
`@punktfunk/plugin-playnite@0.3.0` cannot publish anything at all. Not a launcher tile:
not one game. That is a regression against a shipped plugin, and it is the same hole
`launcher_ui` was created to close, one kind further along.

Two kinds, both host-owned so D1 holds — the plugin supplies a validated VALUE and
never a command line:

  playnite     valued by the game's GUID; resolves to
               explorer.exe "playnite://playnite/start/<guid>", the same
               protocol-via-a-concrete-EXE shape the `epic` kind uses. GUID-validated
               on the way in (so a bad value is a 400 the plugin author can act on)
               and again at launch.

  launcher_ui  now accepts "playnite" on Windows, resolving to
               Playnite.FullscreenApp.exe with Playnite's own install dir as the
               working directory.

Fullscreen, not Desktop, is the whole point of a couch tile — and it is also why this
one cannot ride the URI the games use: probed on .173, Playnite's registered
`playnite://` handler is bound to Playnite.DesktopApp.exe, so no URI opens fullscreen
mode. The exe is spawned directly, with the install dir read from Playnite's own
uninstall entry (HKCU, then HKLM for a machine-wide install), falling back to
%LOCALAPPDATA%\Playnite.

`valid_launcher_ui("playnite")` is answered by RESOLUTION rather than by a static list:
a host without Playnite installed refuses the entry instead of publishing a tile that
does nothing when a user clicks it. That is the same instinct that left Epic, GOG
Galaxy and the Xbox app off the list — each still needs its own verified activation,
and a guess would ship exactly that dead tile.

Gates: punktfunk-host 436 passed / 0 failed on .21 (the Linux arms), and the Windows
arms compiled and their library tests run on .173.
`local_art_bytes_is_confined_and_image_only` and `posix_local_art_is_classified_and_proxied`
built their `file://` values as `format!("file://{path}")`. On Windows that yields
`file://C:\covers\cover.png`, whose authority is `C:` — a UNC reference, not a local
file — so the read half failed on the box and the host suite was red there.

The parser is right and the tests were wrong: `@punktfunk/plugin-kit/library`'s `fileUrl`
emits `file:///C:/covers/cover.png` (three slashes, forward separators) and
`file_url_to_path` documents exactly that. A shared `file_url` helper now builds the value
the way the kit does, so both tests exercise the real plugin contract on both platforms
rather than a shape no plugin ever sends.

Found while gating the Playnite launch kinds on .173 — Linux CI never compiles these arms,
so the failure had gone unnoticed. Test-only: no product code changes.
fix(clients/windows): clippy's manual_is_multiple_of on the rescan tick
android / android (pull_request) Successful in 6m8s
ci / web (pull_request) Successful in 4m50s
ci / docs-site (pull_request) Successful in 1m14s
ci / rust-arm64 (pull_request) Successful in 5m50s
apple / swift (pull_request) Successful in 1m32s
apple / screenshots (pull_request) Skipped
ci / rust (pull_request) Successful in 12m37s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m18s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m13s
58ee74cb58
`cargo clippy -p punktfunk-client-windows -- -D warnings` fails on main with the
pinned 1.96.0 toolchain: `ticks % 5 == 0` trips `manual_is_multiple_of`. Pre-existing
and not from this branch — found while gating the launcher work on .173, because
neither macOS nor Linux ever compiles this crate.

Clippy's own suggestion, applied verbatim.
enricobuehler merged commit 3f8a70dd45 into main 2026-08-06 13:03:45 +00:00
enricobuehler deleted branch worktree-library-clients 2026-08-06 13:03:48 +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#70