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.
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.
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.
`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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The bridge half (#59) gave the host everything it needs to describe a launcher entry —
role: "launcher", thesteam_uiandlauncher_uikinds — 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-coredecodedroleinto anis_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:
"launcher"is a game, and a host that omits the field renders exactly as before (D4's intended degradation);The grouping is enforced once per client stack, not per screen: it is an invariant of
LibraryShared::set_gamesfor 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
--libraryon 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.0cannot publish anything on this host. It emitskind: "command"for every game, the 2026-08-05 review madecommandoperator-only, andprivileged_fieldrefuses 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 holelauncher_uiwas created to close, one kind further along.Two host-owned kinds, so D1 holds (the plugin supplies a validated value, never a command line):
playniteexplorer.exe "playnite://playnite/start/<guid>"— the same protocol-via-a-concrete-EXE shape theepickind 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 registeredplaynite://handler is bound toPlaynite.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::arttests builtformat!("file://{path}"), which on Windows isfile://C:\…: authorityC:, i.e. a UNC reference. The parser is right (the kit emitsfile:///C:/…) and the tests were wrong; they now build the value the way@punktfunk/plugin-kit/library'sfileUrldoes. Test-only — and that file is thefile://art contract every extracted library plugin depends on.58ee74cb—cargo clippy -p punktfunk-client-windows -- -D warningsfails on main with the pinned 1.96.0 toolchain (manual_is_multiple_ofinconnect.rs). Clippy's own suggestion, applied verbatim.Gates
cargo test -p punktfunk-host.21cargo test -p punktfunk-host --bin punktfunk-host library::.173cargo test -p pf-console-ui.21cargo checksession + GTK clients.21cargo clippy -D warnings(4 client crates).21cargo clippy -D warningsWindows client.173cargo fmt --all --check.21swift build(fullPunktfunkClient):app:compileDebugKotlinSix 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
gameleasetreats "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.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 warningsstill fails on Windows (undocumented_unsafe_blocks,windows/install.rs:169) — pre-existing, left alone.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.