feat(host/library): launcher_ui can open Heroic's console mode

Heroic 2.21 added a fullscreen gamepad UI — the Big Picture of that launcher,
and the tile you actually want on a stream. Nothing could publish it: a plugin
names a launcher UI and the host builds the line, and the only Heroic value the
host knew was `heroic`, which opens the ordinary window.

`launcher_ui` now also takes `heroic-console` on Linux, resolving to the same
prefix `heroic` does — native binary if on PATH, else the Flatpak — plus
`--console --fullscreen`. Both flags, because Heroic reads them separately:
`--console` only routes the UI to that front end (`isCLIConsoleMode`) and
`--fullscreen` is what fills the screen (`isCLIFullscreen`). No URI can do it —
`heroic://` speaks `ping` and `launch` and nothing else — which is the same
reason Playnite's fullscreen tile spawns its exe directly rather than going
through the registered protocol handler. An older Heroic ignores the unknown
`--console` and still honours `--fullscreen`, so the tile degrades to a
fullscreen desktop UI rather than to nothing.

That makes the value a launcher UI rather than a launcher. It already was one on
Windows, where `playnite` has always meant `Playnite.FullscreenApp.exe`; the doc
comment claiming otherwise is corrected. A `heroic_ui` kind mirroring `steam_ui`
would have been tidier and was rejected on the compatibility asymmetry: an
unknown KIND degrades to an unlaunchable tile, but an unknown VALUE is a hard
400 that refuses the whole reconcile, so either shape has to be gated on
`minHost` in the plugin index and the value is much the smaller change. A plugin
publishing `heroic-console` must set `minHost` to the release carrying this.

Second fix, from the same reading: `resolvable_launcher_ui` special-cased only
Playnite-on-Windows and answered `true` for everything else, so a Heroic tile
survived `sanitize_launcher_entries` even when `heroic_launch_prefix()` resolves
to nothing. Keeping `~/.config/heroic` after uninstalling Heroic is enough to
reach that — the plugin's `detect` only looks for the directory — and the
operator got a launcher tile that does nothing. Both Heroic values now probe the
prefix, exactly as Playnite probes for its exe.
This commit is contained in:
2026-08-26 19:09:56 +02:00
parent 1198522931
commit ae13b29abd
4 changed files with 91 additions and 8 deletions
+25
View File
@@ -420,6 +420,31 @@ feature surfaces ("--browse needs the console UI", exit non-zero).
identity-selection gate runs where the origin-isolation gate already did. Both have the same
property: a failure mode only a browser would catch.
### `launcher_ui` grows a second Heroic value, for its console mode
**Plugin-facing.** `launcher_ui` accepts **`heroic-console`** on Linux, alongside `heroic` and
`lutris`. It resolves to the same prefix `heroic` does — the native binary if on `PATH`, else the
Flatpak — plus `--console --fullscreen`.
Heroic 2.21 added a fullscreen gamepad UI, and it takes **two** flags: `--console` only routes the
UI to that front end (`isCLIConsoleMode`), and `--fullscreen` is what fills the screen
(`isCLIFullscreen`). Neither is reachable by URI — `heroic://` speaks only `ping` and `launch` — so
this is the same shape as Playnite's fullscreen tile on Windows, where the registered protocol
handler can only open the desktop app.
That makes `launcher_ui`'s value a launcher **UI** rather than a launcher, which it already was on
Windows (`playnite` has always meant `Playnite.FullscreenApp.exe`). A `heroic_ui` kind mirroring
`steam_ui` would have been tidier and was rejected: an unknown *kind* degrades to an unlaunchable
tile on an N-1 host, but an unknown *value* is a hard 400 that refuses the whole reconcile — so
either shape has to be gated on `minHost` in the plugin index, and the value is the smaller change.
**A plugin publishing `heroic-console` must set `minHost` to this release.**
Also here: `resolvable_launcher_ui` now probes `heroic_launch_prefix()` for both Heroic values, the
way it already did for Playnite. Both tiles are dropped from a reconcile on a box where Heroic
cannot be resolved, instead of being published as tiles that do nothing — reachable by keeping
`~/.config/heroic` after uninstalling Heroic, since the plugin's `detect` only looks for that
directory.
---
## v0.31.3
+60 -4
View File
@@ -165,6 +165,21 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
// 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(),
// Heroic's console mode — its couch front end, the Big Picture of this launcher.
//
// It takes TWO flags, which is not obvious and is why this is the host's business and
// not a plugin's: `--console` only routes the UI to that front end, and `--fullscreen`
// is what actually fills the screen (Heroic reads them separately —
// `isCLIConsoleMode` / `isCLIFullscreen`). Neither is a URI: `heroic://` speaks only
// `ping` and `launch`, so a protocol hand-off cannot reach console mode at all — the
// same reason Playnite's fullscreen tile spawns its exe directly.
//
// Console mode arrived in Heroic 2.21.0. An older Heroic ignores the unknown
// `--console` and honours `--fullscreen`, so the tile degrades to a fullscreen desktop
// UI rather than to nothing.
"heroic-console" => {
heroic_launch_prefix().map(|p| format!("{p} --console --fullscreen"))
}
// 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()),
@@ -532,9 +547,16 @@ pub(crate) fn valid_playnite_id(value: &str) -> bool {
/// 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.
/// One kind for every launcher but Steam, rather than one kind each. A value names a launcher *UI*,
/// which for most of them is the same thing as naming the launcher — and where it is not, the value
/// says which one: `heroic` opens Heroic's window, `heroic-console` its couch front end, and on
/// Windows `playnite` has always meant Playnite's **Fullscreen** app rather than its desktop one.
/// Steam keeps its own [`valid_steam_ui`] kind because it was the first launcher with two UIs worth
/// opening, and because both of its values are the same length of word — splitting Heroic's two out
/// into a `heroic_ui` kind today would buy symmetry and cost every N-1 host: an unknown *kind*
/// degrades to an unlaunchable tile, but an unknown *value* is a hard 400 that refuses the whole
/// reconcile, so a new value is the shape that has to be gated on `minHost` in the plugin index
/// either way.
///
/// 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
@@ -548,7 +570,7 @@ pub(crate) fn valid_playnite_id(value: &str) -> bool {
fn launcher_ui_stores() -> &'static [&'static str] {
#[cfg(target_os = "linux")]
{
&["heroic", "lutris"]
&["heroic", "heroic-console", "lutris"]
}
// Playnite's activation is verified (2026-08-06, on the .173 box); Epic, GOG Galaxy and the
// Xbox app are still unwired — each needs its own verified activation, and an unverified guess
@@ -594,6 +616,14 @@ pub(crate) fn resolvable_launcher_ui(value: &str) -> bool {
if value == "playnite" {
return playnite_fullscreen_exe().is_some();
}
// Same question for both Heroic tiles, and the same answer: they resolve to whatever
// `heroic_launch_prefix` finds, so when that finds nothing the tile is dead and must not be
// published. Keeping `~/.config/heroic` around after uninstalling Heroic is enough to reach
// this — the plugin's `detect` only looks for that directory.
#[cfg(target_os = "linux")]
if matches!(value, "heroic" | "heroic-console") {
return heroic_launch_prefix().is_some();
}
true
}
@@ -1123,10 +1153,21 @@ mod tests {
#[cfg(target_os = "linux")]
{
assert!(known_launcher_ui("heroic"));
assert!(known_launcher_ui("heroic-console"));
assert!(known_launcher_ui("lutris"));
// Not wired on this OS — outside the vocabulary, so it is refused inbound rather than
// becoming a tile that does nothing.
assert!(!known_launcher_ui("gog"));
// Both Heroic tiles resolve through the same probe, so a box without Heroic drops both
// rather than publishing one dead tile beside the other.
assert_eq!(
resolvable_launcher_ui("heroic"),
heroic_launch_prefix().is_some()
);
assert_eq!(
resolvable_launcher_ui("heroic-console"),
heroic_launch_prefix().is_some()
);
}
#[cfg(windows)]
{
@@ -1253,6 +1294,21 @@ mod tests {
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!(
!cmd.contains("--console"),
"that is the other tile: {cmd:?}"
);
}
// Console mode needs BOTH flags — `--console` alone routes the UI without filling the
// screen, which from a couch is the bug this tile exists to avoid. Same prefix as the
// window tile, so it is `None` on the same boxes.
assert_eq!(ui("heroic-console").is_some(), ui("heroic").is_some());
if let Some(cmd) = ui("heroic-console") {
assert!(cmd.contains("--console"), "{cmd:?}");
assert!(cmd.contains("--fullscreen"), "{cmd:?}");
assert!(!cmd.contains("--no-gui"), "the GUI is the point: {cmd:?}");
// Gamescope spawns by `split_whitespace`, so every token has to stand alone.
assert!(cmd.split_whitespace().any(|t| t == "--console"), "{cmd:?}");
}
assert_eq!(ui("nonsense"), None);
assert_eq!(ui(""), None);
+5 -3
View File
@@ -113,9 +113,11 @@ A [plugin](/docs/plugins) can own a slice of the library and keep it in sync —
Manager and Playnite plugins get your collection into the grid, box art and all.
A library plugin can also publish a **launcher tile** — an entry that opens Steam Big Picture,
Heroic, Lutris or Playnite itself rather than a game, so you can install or fix something from the
couch. Clients group those into their own row above your titles, each drawing its launcher's logo.
A launcher tile you don't want is a switch in that plugin's settings.
Heroic's console mode, Lutris or Playnite Fullscreen itself rather than a game, so you can install
or fix something from the couch. Where a launcher has both a couch UI and an ordinary window, they
are separate tiles: Steam Big Picture beside the Steam client, Heroic Console Mode beside the Heroic
window. Clients group them all into their own row above your titles, each drawing its launcher's
logo. A launcher tile you don't want is a switch in that plugin's settings.
Entries a plugin owns are read-only to you. The host refuses a hand edit or delete of one, because
the next sync would overwrite it anyway — change the title at its source and let the plugin sync
+1 -1
View File
@@ -28,7 +28,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 |
* | `launcher_ui` | which launcher UI to open: `heroic` \| `heroic-console` \| `lutris` | 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 |