Library & launching
Beyond the backup safety net, the plugin renders a full-screen library you can browse and launch from in Game Mode. The QAM panel’s Browse Library button navigates to it.
Routes
Section titled “Routes”The frontend registers its pages through Decky’s routerHook at plugin load (src/index.tsx), using the constants in src/constants.ts:
| Constant | Path | Page |
|---|---|---|
SPOOL_ROUTE | /spool (exact) | SpoolPage — library grid + LAN button |
SPOOL_GAME_ROUTE | /spool/game/:id | GameDetailPage |
SPOOL_LAN_ROUTE | /spool/lan (exact) | LanPage — see LAN browsing |
SPOOL_LAN_PEER_ROUTE | /spool/lan/:peerAddr/:peerPort (exact) | PeerGamesPage |
SPOOL_LAN_GAME_ROUTE | /spool/lan-game/:peerAddr/:peerPort/:gameId | PeerGameDetailPage |
/spool is registered exact because otherwise it prefix-matches /spool/game/:id and shadows the detail page — the first matching <Route> in the Switch wins. Each page is its own route (rather than nested state) so the hardware B button walks the back-stack naturally: a peer’s game list backs out to the peers list, which backs out to the library.
All pages resolve the headless server base URL once via the useServerBase() hook (which calls the get_server_base callable) and talk to the server directly over loopback HTTP.
Library grid
Section titled “Library grid”LibraryGrid fetches GET ${base}/library and renders a CoverGrid of tiles. Covers load by URL from ${base}/covers/<filename> (the filename is taken from each entry’s cover_image_path). It shows “Loading…”, “Couldn’t load your library.”, or “No games in your library yet.” as appropriate. Activating a tile navigates to /spool/game/:id.
GameDetailPage re-fetches the library to find the game by id, renders a full-bleed hero background (GET ${base}/games/:id/steam-art/hero), the portrait cover, playtime, last-played, and the sync badge, plus a Play button that calls launchLibraryGame.
Launching a game
Section titled “Launching a game”Launching from Game Mode means getting Steam to run the game so it shows up in the running-game UI and the post-session flow works. The plugin does this by creating (or reusing) a non-Steam shortcut live via SteamClient.Apps — no Steam restart — then asking Steam to run it. This mirrors what desktop “Add to Steam” writes, so a game added either way ends up with the same shortcut. The flow lives in src/lib/launch.ts and src/lib/steam.ts.
flowchart TD
PLAY["Play tapped"] --> INFO["GET /games/:id/steam-launch-info<br/>{ appName, exe, startDir, launchOptions }"]
INFO --> RESOLVE{"known appid?<br/>(localStorage / shortcut_app_id)"}
RESOLVE -->|yes| RUN
RESOLVE -->|no| ADD["SteamClient.Apps.AddShortcut(...)<br/>reinforce setters + apply artwork"]
ADD --> REMEMBER["remember appid in localStorage"]
REMEMBER --> RUN["resolve gameid via appStore.m_mapApps<br/>→ Apps.RunGame"]
RUN --> SPOOL["Steam runs spool --run → attached workflow"]
-
Launch info —
GET ${base}/games/:id/steam-launch-inforeturns the shortcut fields. Theexeis Spool’s stable launcher (paths::spool_executable— thespool-launcher.shwrapper on AppImage installs), andlaunchOptionsis--run "<name>" "<game exe>", which the Game-Mode attached--runflow consumes. -
Resolve the appid — the exe and start-dir are passed to Steam quoted (literal surrounding double-quotes), matching how the server computes the shortcut’s CRC appid (
steam::compute_shortcut_app_idCRCs the quoted exe). The plugin reuses an appid from three sources, in order:- a per-game appid remembered in
localStorage(src/lib/appid-map.ts, keyed by game id), - the server-computed
shortcut_app_idinjected into each/libraryentry (used when localStorage is stale or cleared), - otherwise it calls
AddShortcut(appName, quotedExe, quotedStartDir, launchOptions), which returns a fresh appid.
When it adds a new shortcut it reinforces every field via the explicit setters.
SetAppLaunchOptionsis the one that actually sticks — without it the launcher would run with no--runargs and nothing would launch. It then applies library artwork (best-effort) and remembers the appid. - a per-game appid remembered in
-
Run it — the authoritative
gameidis read from Steam’s in-memory app store (appStore.m_mapApps.get(appid).m_gameid, polled briefly until the shortcut registers) rather than computed by hand. The launch then triesSteamClient.Apps.RunGame, falling back toURL.ExecuteSteamURL("steam://rungameid/…"), thenNavigation.Navigate.
Steam then runs spool --run "<name>" "<exe>", which triggers Spool’s existing attached-launch workflow (restore → play → backup) and writes the session record the forced-close safety net depends on.
Matching a Steam appid back to a Spool game
Section titled “Matching a Steam appid back to a Spool game”findSpoolGame (used by the launch flow and the playtime badge) maps a Steam appid to a library entry by checking, in order: a matching steam_id (a native Steam game Spool also tracks), a matching shortcut_app_id (a shortcut created server-side), then the localStorage inverse map (a shortcut created live via the plugin).
Artwork
Section titled “Artwork”applyArtwork (src/lib/steam.ts) pulls four art kinds from the server and sets them live via SetCustomArtworkForApp, mapping each to Steam’s ELibraryAssetType: capsule (portrait, 0), hero (banner, 1), logo (transparent title, 2), header (wide capsule, 3). Server-side (get_steam_art), portrait and hero come straight off Spool’s on-disk art (so they work even with SteamGridDB disabled); logo and the wide header are fetched live from SteamGridDB (header → SteamGridDB’s landscape grid). WebP images are transcoded to PNG because SetCustomArtworkForApp rejects WebP. Any kind that 404s is silently skipped, and art failures never block the launch.