End-to-end examples for using the toolkit against a live Steam Deck.
- Connection setup
- Running CDP probes
- Writing a custom probe
- Retargeting to your plugin
- Screenshot pipeline
- Perf bench
- Class-map injection
- CDP target conventions
- Common pitfalls
DeckProbe expects an .env file at the parent repo root (the repo that
contains the devkit/ folder). The CLI auto-loads it before invoking
any subcommand, so individual scripts never need to know about it.
Minimum .env:
DECK_HOST=192.168.1.42 # IP or hostname of the deck
DECK_USER=deck # SSH user (almost always `deck`)
DECK_SUDO_PASS=... # SteamOS password, used for `sudo -S`
DECK_CDP_PORT=8081 # default CDP port
# DECK_CDP_HOST=... # only if you tunnel CDP; defaults to DECK_HOSTMake sure Steam is launched with CDP enabled. On SteamOS that's the
default when Decky is installed; otherwise add --cef-enable-debugging
to the Steam launch flags.
Verify connectivity:
curl -s http://$DECK_HOST:$DECK_CDP_PORT/json | python3 -m json.toolYou should see a list of CDP targets — at minimum SharedJSContext and Steam — Big Picture.
# List every diag script available (built-in + your project's)
python3 devkit/cli.py diag list
# Run a probe (target auto-resolved from a substring of its title)
python3 devkit/cli.py diag run diag_layout
# Smoke probe of the home: ensures mount + rows + cards are present
python3 devkit/cli.py probe --mode smoke
# Inspect every row's title + card count + cards in the home mount
python3 devkit/cli.py probe --mode rowsThe cli.py script forwards arguments straight to the chosen probe and
inherits the .env-loaded environment.
The simplest probe is a .cjs file that imports runAndPrint from the
shared CDP helper, builds a JS expression as a string, and pipes the
result to stdout:
// devkit/diag/diag_card_count.cjs
'use strict';
const { runAndPrint } = require('./_lib/cdp');
const expr = `(function(){
const mount = document.getElementById('deck-shelves-home-root');
if (!mount) return { error: 'no mount' };
return {
cards: mount.querySelectorAll('.ds-card').length,
rows: mount.querySelectorAll('.ds-row-scroll').length,
};
})()`;
runAndPrint('bp', expr);Run it:
node devkit/diag/diag_card_count.cjsWhen this exact expression runs against a plugin OTHER than the default
project, _lib/cdp.cjs automatically swaps every default selector for
the configured one (DEVKIT_HOME_MOUNT_ID, DEVKIT_CARD_SEL, …). No
edits required — write the probe once, run it everywhere.
DeckProbe ships with selector defaults that match the Deck Shelves home mount. To target a different plugin's DOM:
DEVKIT_HOME_MOUNT_ID=my-plugin-home-root \
DEVKIT_CARD_SEL='.card-grid > .card' \
DEVKIT_ROW_SEL='.card-grid' \
DEVKIT_QAM_SCOPE_SEL='.my-plugin-qam' \
DEVKIT_ABOUT_ROUTE='/my-plugin/about' \
python3 devkit/cli.py probe --mode smokeThe full list lives in devkit/lib/selectors.py (Python) and
devkit/lib/selectors.cjs (Node). Both files document every env var.
For project-specific diag scripts that don't fit the generic pattern,
drop them in scripts/devkit-ext/ at the parent repo root and the CLI
will pick them up automatically:
my-plugin/
├── devkit/ # this toolkit (submodule or copy)
├── scripts/
│ └── devkit-ext/
│ └── diag/
│ └── my_custom_probe.cjs
└── .env
You can also point DEVKIT_DIAG_DIRS=... at any folder hierarchy you
prefer (colon-separated, like PATH).
# Take screenshots in every supported locale
python3 devkit/cli.py screenshot
# Single locale only
python3 devkit/cli.py screenshot --locale en-US
# Keep previous output instead of wiping `screenshots/out/`
python3 devkit/cli.py screenshot --keep-existingThe pipeline navigates the deck through canonical states (home, QAM,
about, settings detail panels), captures each, and writes localised
results to screenshots/out/<locale>/. Point
DEVKIT_SCREENSHOT_SCRIPT=... at a project-specific entry point if you
need to customise the navigation flow.
pnpm --filter @steamdeck/devkit perf:bench
# or
python3 devkit/perf-bench.pyCaptures frame-time + memory snapshots over a configurable window. The
output goes to perf-output/ as JSON. Use as a regression gate when
changing the home rendering path.
Steam re-randomises its CSS-Modules class hashes on every minor update. DeckProbe ships a class-map injector so probes / overlays survive those updates without code changes.
CLASS_MAP='{"viewport":"_3PhG...","row":"ds-row-scroll","card":"ds-card"}' \
DEVKIT_CLASS_MAP_GLOBAL=__MY_PLUGIN_CLASS_MAP \
DEVKIT_CLASS_MAP_LS_KEY=my_plugin_class_map \
python3 devkit/tools/inject_classmap.pyThe injector writes the map to both window[...GLOBAL] (live) and
localStorage[...LS_KEY] (persistent across reloads).
Probes accept a short target alias resolved by _lib/cdp.cjs /
lib/cdp.py:
| Alias | Resolves to (title substring) |
|---|---|
bp |
Big Picture |
qam |
QuickAccess |
shared |
SharedJSContext |
mainmenu |
MainMenu |
<prefix> |
Any CDP target id prefix |
You can also pass the full 32-char target id directly.
- Probe runs but returns
{}— most often the wrong target. The home mount lives inbp, the QAM scope inqam, plugin-module state inshared. Try--targetor use the alias table above. - CDP returns the wrong target — Steam reuses target ids across reloads. Pass the full id if a prefix matches more than one target.
offsetHeight === 0— Steam often renders hidden containers as zero-height shells. UsegetBoundingClientRect()AND check for visible focusable descendants instead of trustingoffsetHeight.- Promise hangs forever — wrap any backend RPC in a timeout. Decky RPCs occasionally stall and the CDP probe never returns.
- Stale classes — re-run
inject_classmap.pyafter a Steam update, or your selectors will miss everything Steam now hashes differently.