feat(library): launcher tiles a plugin can actually publish
ci / docs-site (pull_request) Successful in 1m14s
apple / swift (pull_request) Successful in 1m28s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m10s
ci / rust (pull_request) Successful in 6m11s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 6m56s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m35s

Design D4 promised entries that open the LAUNCHER — Steam Big Picture, Heroic,
Lutris — and the plumbing for it landed in M2/M4: the `role` field, the
`steam_ui` kind, the console's Launchers rail. But nothing could flow through it
for anything except Steam.

D4 said the other launchers would ride the `command` kind. The 2026-08-05 review
then made `launch.kind = "command"` operator-only (it is handed to a shell), so a
plugin publishing one is refused with a 403. The two changes are individually
right and jointly leave a hole: `steam_ui` was the only launcher kind a plugin
could publish, so a Heroic or Lutris tile was unreachable.

New `launcher_ui` kind, valued by store id. One kind rather than one per store
because every launcher except Steam has exactly a single UI to open; Steam keeps
its own kind because it genuinely has two. D1 is preserved — the plugin names a
launcher, the host builds the command, and no shell string crosses the wire:

  heroic -> the same native-or-Flatpak resolution the `heroic` game kind uses,
            minus --no-gui and minus the URI, so the window itself opens
  lutris -> bare `lutris`, which opens the window (the URI form is `lutris_id`)

Platform-gated to what this host can actually resolve, and validated INBOUND: a
value naming a launcher this OS cannot open is a 400 the plugin author can act
on, not a tile that silently does nothing when a user clicks it. Windows
launchers (Epic, GOG Galaxy, Xbox app) are deliberately absent — each needs its
own verified activation and a guess would ship exactly that dead tile.

Also closes a WP4.3 item I under-delivered and did not flag: the console's
add/edit form had no way to mark an entry as a launcher, so even hand-adding one
was impossible. It now has the checkbox — and `formFrom` round-trips it, without
which editing a launcher entry would silently demote it to a game, which is the
precise bug that file's own comment warns about.

Gates on .21: punktfunk-host 435 passed / 0 failed (two new), workspace clippy
-D warnings clean, cargo fmt --all --check clean, OpenAPI drift green. Console:
orval + paraglide regen, tsc clean, check-i18n at 604 messages for en + de.

Still unproven on hardware: no launcher tile has been clicked on a real host.
The steam plugin (the first to emit one) is not built yet.
This commit is contained in:
2026-08-05 21:12:19 +02:00
parent d2085879da
commit 6f07bd94d3
8 changed files with 146 additions and 11 deletions
+11 -2
View File
@@ -371,8 +371,8 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
/// copies of the very primitive the `/hooks` carve-out exists to withhold.
///
/// Returns the field name for the error message, so a plugin author sees exactly what was refused.
/// The other launch kinds (`steam_appid`, `steam_ui`, `epic`, `gog`, `aumid`, `lutris_id`,
/// `heroic`) are all
/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`,
/// `lutris_id`, `heroic`) are all
/// host-resolved from a validated id and stay open to every lane — a provider plugin can still
/// publish its whole catalogue, it just cannot hand the host a shell command to run.
pub fn privileged_field(
@@ -452,6 +452,15 @@ pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), St
"entries[{i}]: `launch.value` for kind `steam_ui` must be `bigpicture` or `desktop`"
));
}
// Refused rather than silently accepted, because the failure is otherwise invisible
// until a user clicks the tile: an unresolvable value yields no command at launch time.
if launch.kind == "launcher_ui" && !valid_launcher_ui(&launch.value) {
return Err(format!(
"entries[{i}]: `launch.value` for kind `launcher_ui` names a launcher this host \
cannot open (`{}`)",
launch.value
));
}
}
if let Some(marker) = &e.detect.env_marker {
if !valid_env_key(&marker.key) {
@@ -93,6 +93,18 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
"desktop" => Some("steam".into()),
_ => None,
},
// The other launchers' own UIs (D4). The host builds the command — a plugin only names
// which launcher — so no shell string ever crosses the wire.
#[cfg(target_os = "linux")]
"launcher_ui" => match spec.value.as_str() {
// The same resolution the `heroic` game launches use (native binary, else Flatpak), just
// without `--no-gui` and without a URI: that opens Heroic's window, which IS the tile.
"heroic" => heroic_launch_prefix(),
// Bare `lutris` opens the Lutris window; with a `lutris:rungameid/…` URI it launches a
// game instead (the `lutris_id` kind above).
"lutris" => Some("lutris".into()),
_ => None,
},
// Trusted: the command comes from the host's own custom store, never the client.
"command" => (!spec.value.trim().is_empty()).then(|| spec.value.clone()),
_ => None,
@@ -248,6 +260,39 @@ pub(crate) fn valid_steam_ui(value: &str) -> bool {
matches!(value, "bigpicture" | "desktop")
}
/// The launcher UIs **this host** can open, as `launcher_ui` values (D4).
///
/// One kind for every launcher but Steam, rather than one kind each: they all have exactly a single
/// UI to open, so the value is just which launcher. Steam keeps its own [`valid_steam_ui`] kind
/// because it has two (Big Picture and the desktop client), which is a genuinely different choice.
///
/// Platform-gated, because a value naming a launcher this OS cannot run is not a tile that merely
/// looks odd — it is one that fails at launch. Validated inbound too, so a plugin gets a 400 it can
/// act on instead of publishing a dead entry.
///
/// **Why a typed kind at all**, when design D4 originally said non-Steam launchers would ride the
/// `command` kind: the 2026-08-05 review made `launch.kind = "command"` operator-only (it is handed
/// to a shell), so a plugin publishing one is refused. A typed kind keeps D1's rule intact — the
/// plugin supplies a validated *value*, the host builds the command — and is the only way a scanner
/// plugin can offer a launcher tile at all.
fn launcher_ui_stores() -> &'static [&'static str] {
#[cfg(target_os = "linux")]
{
&["heroic", "lutris"]
}
// Windows launchers (Epic, GOG Galaxy, the Xbox app) are not wired yet — each needs its own
// verified activation, and an unverified guess would ship a tile that does nothing.
#[cfg(not(target_os = "linux"))]
{
&[]
}
}
/// Is this a `launcher_ui` value this host can resolve?
pub(crate) fn valid_launcher_ui(value: &str) -> bool {
launcher_ui_stores().contains(&value)
}
/// Map a `heroic` LaunchSpec value (`<runner>:<appName>`) to the Heroic launch command, run nested in
/// gamescope. The host owns this mapping; the client only ever sends the id. CAVEAT: Heroic is a
/// single-instance Electron app — in a fresh per-session gamescope it boots, launches the game (which
@@ -509,6 +554,50 @@ mod tests {
assert!(!valid_steam_ui("bigpicture; rm -rf ~"));
}
/// The `launcher_ui` kind exists because D4's original plan — non-Steam launchers riding the
/// `command` kind — stopped being available to plugins when the 2026-08-05 review made
/// `command` operator-only. A plugin names a launcher; the host builds the command.
#[test]
fn launcher_ui_accepts_only_launchers_this_host_can_open() {
#[cfg(target_os = "linux")]
{
assert!(valid_launcher_ui("heroic"));
assert!(valid_launcher_ui("lutris"));
// Not wired on this OS — refused inbound rather than becoming a tile that does nothing.
assert!(!valid_launcher_ui("gog"));
}
#[cfg(not(target_os = "linux"))]
{
// No Windows/macOS launcher UIs are wired yet, so every value is refused.
assert!(!valid_launcher_ui("heroic"));
assert!(!valid_launcher_ui("gog"));
}
assert!(!valid_launcher_ui(""));
assert!(!valid_launcher_ui("lutris; rm -rf ~"));
}
#[cfg(target_os = "linux")]
#[test]
fn launcher_ui_opens_the_launcher_itself() {
let ui = |v: &str| {
command_for(&LaunchSpec {
kind: "launcher_ui".into(),
value: v.into(),
})
};
// Bare `lutris` opens the window; the URI form is the `lutris_id` kind and launches a game.
assert_eq!(ui("lutris").as_deref(), Some("lutris"));
assert!(!ui("lutris").unwrap().contains("rungameid"));
// Heroic resolves the same way its game launches do, but with no `--no-gui` and no URI — so
// the window IS what opens. `None` on a box without Heroic, which is a correct answer.
if let Some(cmd) = ui("heroic") {
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
assert!(!cmd.contains("heroic://"), "no game URI: {cmd:?}");
}
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
}
#[cfg(not(windows))]
#[test]
fn steam_ui_resolves_to_the_client_ui_on_linux() {
+2 -1
View File
@@ -31,7 +31,8 @@ fn check_entry_fields(
&format!(
"`{field}` is executed as the host user and may only be set with the \
operator's admin token — a plugin may publish entries with any host-resolved \
launch kind (steam_appid, steam_ui, epic, gog, aumid, lutris_id, heroic) instead"
launch kind (steam_appid, steam_ui, launcher_ui, epic, gog, aumid, lutris_id, heroic) \
instead"
),
));
}
+1
View File
@@ -26,6 +26,7 @@ export type Artwork = typeof Artwork.Type;
* | `command` | a shell command (operator-trust tier) | both |
* | `steam_appid` | digits an appid, or a 64-bit non-Steam-shortcut game id | both |
* | `steam_ui` | `bigpicture` \| `desktop` opens the Steam client itself | both |
* | `launcher_ui` | a store id (`heroic`, `lutris`) opens that launcher's own UI | linux |
* | `lutris_id` | digits a pga.db game id | linux |
* | `heroic` | `<runner>:<appName>`, runner legendary/gog/nile | linux |
* | `epic` | `<namespace>:<catalogItemId>:<appName>` or a bare appName | windows |
+9 -7
View File
@@ -10,11 +10,11 @@
"nav_library": "Bibliothek",
"nav_plugins": "Plugins",
"plugin_offline_title": "Dieses Plugin läuft nicht",
"plugin_origin_untrusted_title": "Port dieses Plugins einmal best\u00e4tigen",
"plugin_origin_untrusted_hint": "Plugin-Oberfl\u00e4chen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser vertraut dem Zertifikat dieses Hosts f\u00fcr den Konsolen-Port, aber noch nicht f\u00fcr diesen — und in einem Frame kann er nicht nachfragen. \u00d6ffne ihn einmal in einem Tab, best\u00e4tige das Zertifikat und komm zur\u00fcck.",
"plugin_origin_untrusted_open": "In neuem Tab \u00f6ffnen",
"plugin_origin_unavailable_title": "Plugin-Oberfl\u00e4chen sind nicht verf\u00fcgbar",
"plugin_origin_unavailable_hint": "Plugin-Oberfl\u00e4chen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht ge\u00f6ffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.",
"plugin_origin_untrusted_title": "Port dieses Plugins einmal bestätigen",
"plugin_origin_untrusted_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dein Browser vertraut dem Zertifikat dieses Hosts für den Konsolen-Port, aber noch nicht für diesen — und in einem Frame kann er nicht nachfragen. Öffne ihn einmal in einem Tab, bestätige das Zertifikat und komm zurück.",
"plugin_origin_untrusted_open": "In neuem Tab öffnen",
"plugin_origin_unavailable_title": "Plugin-Oberflächen sind nicht verfügbar",
"plugin_origin_unavailable_hint": "Plugin-Oberflächen laufen auf einem eigenen Port, damit ein Plugin nicht in deinem Namen auf der Konsole handeln kann. Dieser Port konnte nicht geöffnet werden, deshalb bleiben sie deaktiviert. Sieh ins Konsolen-Log, setze dann PUNKTFUNK_UI_PLUGIN_PORT auf einen freien Port und starte neu.",
"plugin_offline_hint": "Starte den Scripting-Runner und versuche es erneut.",
"plugin_retry": "Erneut versuchen",
"plugin_open_new_tab": "In neuem Tab öffnen",
@@ -276,7 +276,7 @@
"library_field_command": "Startbefehl",
"library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.",
"library_field_password": "Konsolen-Passwort",
"library_field_password_help": "Ein Startbefehl l\u00e4uft auf dem Host mit deinen Rechten. Best\u00e4tige zum Speichern dein Konsolen-Passwort.",
"library_field_password_help": "Ein Startbefehl läuft auf dem Host mit deinen Rechten. Bestätige zum Speichern dein Konsolen-Passwort.",
"library_field_platform": "Plattform",
"library_field_platform_help": "Das System, auf dem dieser Titel läuft, z. B. PS2, Xbox 360, SNES, PC.",
"library_field_description": "Beschreibung",
@@ -601,5 +601,7 @@
"update_result_noop": "Deine Paketquelle hatte noch nichts Neueres — in ein paar Minuten erneut versuchen.",
"update_opt_in": "Um Ein-Klick-Updates von hier zu aktivieren, einmal auf dem Host ausführen (danach ab- und wieder anmelden):",
"update_result_failed": "Update auf {to} ist in Phase {stage} fehlgeschlagen.",
"update_result_log": "Installer-Log:"
"update_result_log": "Installer-Log:",
"library_field_role": "Dieser Eintrag öffnet einen Launcher",
"library_field_role_help": "Zeigt ihn in der Launcher-Reihe über deinen Spielen statt im Raster. Er startet und endet genauso wie sonst."
}
+3 -1
View File
@@ -601,5 +601,7 @@
"update_result_noop": "Your package source had nothing newer yet — try again in a few minutes.",
"update_opt_in": "To enable one-click updates from here, run this once on the host (then log out and back in):",
"update_result_failed": "Update to {to} failed during {stage}.",
"update_result_log": "Installer log:"
"update_result_log": "Installer log:",
"library_field_role": "This entry opens a launcher",
"library_field_role_help": "Shows it in the Launchers row above your games instead of in the grid. It still starts and stops the same way."
}
+30
View File
@@ -27,6 +27,10 @@ interface FormState {
* own comment at the render site (2026-08-05 review M-6). Never round-tripped from the
* server, so it is always empty on open, including when editing an entry that has one. */
password: string;
/** `true` = this entry opens a launcher rather than a game (design D4). Purely presentational:
* the console groups launcher entries into their own rail, and clients that don't know the
* field render them as ordinary tiles. */
isLauncher: boolean;
// Details — the flattened GameMeta fields; numbers and lists are kept as the raw
// text the user typed and only parsed on submit.
platform: string;
@@ -48,6 +52,7 @@ const emptyForm: FormState = {
logo: "",
command: "",
password: "",
isLauncher: false,
platform: "",
description: "",
developer: "",
@@ -68,6 +73,9 @@ function formFrom(entry: GameEntry): FormState {
logo: entry.art.logo ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
password: "",
// Round-tripped like every other field: `update_custom` REPLACES the whole entry, so an
// unread field here would silently demote a launcher entry back to a game on any edit.
isLauncher: entry.role === "launcher",
platform: entry.platform ?? "",
description: entry.description ?? "",
developer: entry.developer ?? "",
@@ -113,6 +121,8 @@ function toInput(f: FormState): CustomInput {
// The BFF re-verifies this and strips it before forwarding; the host never sees the field.
// Only sent when there is a command to authorize, matching the conditional gate.
...(command ? { password: f.password } : {}),
// Omitted when it is the default, matching the host's skip-when-`game` serialization.
...(f.isLauncher ? { role: "launcher" as const } : {}),
platform: trim(f.platform),
description: trim(f.description),
developer: trim(f.developer),
@@ -297,6 +307,26 @@ export const GameForm: FC<{
required
/>
)}
{/* Design D4: a launcher entry opens the launcher itself rather than a title. It
launches and leases like any other entry this only moves it into the
console's Launchers rail. Hand-adding one is the supported way to get a
"Heroic" or "Lutris" tile without installing that source's plugin. */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
id="lib-isLauncher"
type="checkbox"
checked={form.isLauncher}
onChange={(e) =>
setForm((f) => ({ ...f, isLauncher: e.target.checked }))
}
/>
<Label htmlFor="lib-isLauncher">{m.library_field_role()}</Label>
</div>
<p className="text-xs text-muted-foreground">
{m.library_field_role_help()}
</p>
</div>
<fieldset className="space-y-4 border-t pt-2">
<legend className="sr-only">{m.library_details_legend()}</legend>
<p
+1
View File
@@ -16,6 +16,7 @@ const emptyForm = {
// The console-password confirmation the form requires alongside a launch command; empty here
// because the story renders the untouched add form, which has no command yet.
password: "",
isLauncher: false,
platform: "",
description: "",
developer: "",