Add, hide and revive elements in Steam's Big Picture front end, from .NET. This README is the orientation and the
how-to. The contract, with every limit, state and log key, is in
docs/reference.md.
Very early. Expect it to change under you. This was extracted from one application and has one consumer. Names, shapes and whole types are still moving, and the API will break without deprecation cycles. Pin an exact version, read the commit log before bumping, and expect to fix call sites. Issues and pull requests are welcome, but I cannot honestly offer a stability promise yet.
dotnet add package SteamUiToolkit
Steam's client UI is a Chromium application that talks to a CDP client on loopback. This library is about everything after that: doing it in a way that survives a Steam update, cleans up after itself, and tells you why when it does not work.
A persistent CDP transport that owns one connection per target, tracks execution-context and document generations, and verifies the debug port belongs to Steam before connecting.
A patch lifecycle of probe, apply, verify and remove, where each patch declares what it owns, proves it found its target before touching anything, and is removed and re-probed when it cannot be verified.
Three ways to change the client, which between them cover every surface below:
| Way | What it does | What removal owes |
|---|---|---|
| Feed a data construct | supply a store-shaped namespace where the client has none | delete it |
| Answer an RPC | overlay a method the client already has | restore what was displaced |
| Reveal what is gated | flip the one flag or getter hiding a surface the client can already serve | restore the original, never the platform constant |
An extension host, so a consumer can let third parties add surfaces of their own.
Valve's audio page, Internet page, Bluetooth page, brightness slider, Performance tab and TDP rows all ship in the
Windows client and are inert only because nothing answers behind them. Each one is a
Steam*Surface: the injected gate that supplies or reveals it, the patch that probes and verifies it, a typed state
record you fill in, and a backend interface you implement. Quick Access rows built on Valve's own field primitives
(frame limit, variable refresh, resolution, automatic power limit, controller target, charge and lighting) are
Steam*Rows of the same shape. You say "this is our data, and it maps to that feature", and the CEF work stays in here.
SteamNavigationPanelSurface.Module makes Steam's left slideout navigation panel an extension surface. Publish
SteamNavigationPanelState to add entries and hide Steam's own, and implement
ISteamNavigationPanelBackend to answer an added entry's activation. Entries are placed relative to Steam's by route
(/library) or by Valve's descriptor key (power) rather than by index, because routes and keys are stable across
client builds and languages while the labels are localized. Hiding is applied before insertion. An entry whose anchor is
not in the panel goes to the end and is reported as orphaned rather than dropped. The claim is on the one exported
handle and is restored on removal.
SteamPageSurface.Module registers custom pages with Steam's own router. Publish
SteamPageState with a path, a title and an id. The path goes to Steam's matcher, so
/wsgm/artwork/:appid takes parameters the way Valve's routes do. Pages are built with Steam's back-stack Route
rather than react-router's, so they push and pop the back stack like a native page instead of rendering correctly and
losing B. Override decides whether a page replaces a Steam route of the same path or adds a new one: Steam's switch
takes the first match, so an override is inserted ahead of Valve's routes and an addition behind them. Adding is the
default.
SteamLibraryBadgeSurface.Module draws a library badge on every library tile, immediately left of Valve's Steam
Input badge in the tile's icon row: the name of the library holding the game, green when the game is installed and grey
when it is not. Publish SteamLibraryBadgeState with the libraries worth naming and their app ids; a game in none of
them is on the internal library and gets
InternalLabel. The claim is on the tile memo's type, so Home's carousel and the library grid are covered by one
claim, and the badge shows exactly when Valve shows the icon row. Implement
ISteamLibraryBadgeBackend to hear homeLayout, which reports Steam's own Big Art Mode setting when the gate resolves
it and whenever a tile render sees it change.
SteamHomeCarouselSurface.Module makes Big Picture Home's carousel list the games on the libraries attached right
now: the most recently played first, installed games by last played merged with unplayed recent purchases by purchase
time, never-played installed games after those, and owned uninstalled games greyed out when
SteamHomeCarouselState.IncludeUninstalled asks for them. Games in DisconnectedAppIds leave the list. The gate
replaces the one app-id array Home hands its carousel and background, so Steam's own components draw it, and puts the
virtualized carousel's overscan back to the component's default, since Home otherwise mounts every tile. Implement
ISteamHomeCarouselBackend to hear what the carousel holds after each rebuild.
SteamStorageSurface.Module revives Steam's own SteamOS storage management on Windows. The whole UI hangs off one
unanswered service question, so the gate claims SendMsg on the service transport and answers StorageDeviceManager.*
from SteamStorageState. Implement
ISteamStorageBackend to adopt, eject, format and trim. Every other service message Steam sends passes straight through
with its own arguments and receiver, and removal deletes the claim so Valve's method shows through again. The injected
half performs no storage operation itself, which keeps one Windows implementation behind both Steam's pages and your
own.
SteamPowerProfileRow.Module adds a Windows power-profile dropdown to QAM Performance. Supply stable ids, display
labels and observed state through SteamPowerProfileState, and implement
ISteamPowerProfileBackend to validate and apply selections. SteamPowerPresetRow.Module adds independent AC and
battery assignments with SteamPowerPresetState and ISteamPowerPresetBackend. The active preset is read-only, Custom
included. A host may also publish custom as a saved source assignment, which is displayed only for that source and
never sent as a selection command. Empty preset options hide those controls. The toolkit does not change OS power
settings itself.
Performance controls use titled native sections. Quick Settings places display controls before Steam's common settings, then separate Charging and RGB lighting sections.
SteamGameWindowActivation.RaiseAsync requests Steam activation for exactly one existing overlay process. Your host
still has to focus its own selected native window, because Steam can raise a launcher console, and completion does not
mean the overlay recovered. The call borrows the existing transport, has a one-second budget, and never launches or
retries a game.
SteamNativeSurfaceCommands.ReplayAsync invokes Steam's native Home or Quick Access handler on an exact process,
app window identity and CEF generation. It does not retry and does not fall back to another window. The Keyboard
action shows the native keyboard through that exact window's keyboard manager and game-overlay route, and an already
visible keyboard stays open. Snapshots include nullable keyboard visibility.
SteamSideMenuObserver.ReadAsync reads main-window and overlay-window menu state through the host's subscribed
transport. Register SteamOverlayActivationPatch with the patch manager to observe overlay activation too. Unknown
activation stays unknown after attachment or reconnect, and a closed QAM alone does not prove an in-game overlay is
closed.
Bluetooth state includes optional operation progress for Steam's spinner. Failed backend commands return failed transport replies, and device detail queries preserve the semantic device identity.
State callbacks are isolated during both cached replay and later publications, so a failing subscriber cannot interrupt another subscriber or stop installation cleanup being registered.
Power controls use two Valve slider primitives, for sustained (PL1) and boost (PL2) power. Both follow hardware
observations, profile changes included, and write only on a completed user edit. Steam's own saved TDP setting is never
applied or polled. A consumer can also publish Unified and
CanSelectMode and implement SetUnifiedModeAsync, which renders one TDP slider with both limit readbacks; its toggle
saves policy only.
Semantic sliders render hardware observations and suppress unchanged completion echoes, so a live power-limit update does not dispatch a manual write back to its owner.
Brightness hosts publish confirmed percentages with increasing observation revisions and return readback in successful
command responses. The native slider consumes that state without sending programmatic refreshes back as hardware writes.
See SteamBrightnessSurface in the reference.
Each of these cost me a debugging session against a live client.
Every patch carries an ownership marker and accepts "already ours". A patch that cannot recognise its own work either refuses forever or overwrites something that was never its to change, and a probe that requires the pre-patch condition its own apply invalidates tears itself down on every poll.
Removal restores exactly what was displaced, read from the object rather than from the closure that installed it. A
bridge replaced in place has no closure left, and restoring undefined leaves the client worse than never patching.
Reveal the surface, never the platform. Setting Steam's "is this SteamOS" constant gives you the row you wanted and changes unrelated client behaviour everywhere.
Never iterate the webpack module registry constructing exports. Probes find a module by a source fingerprint that matches it alone, and an export by its shape. Enumerating and calling everything once restarted the machine and signed Steam out.
Never name a module id or a minified export name. Client builds renumber modules and rename exports. The September 2026 beta did both and refused every gate that had named them.
Every refusal is logged with its reason, because the injected side has nowhere else to put an error.
eng/check-ownership-claims.mjs runs the claim primitives out of the emitted prelude, the actual bytes that get
injected, against those scenarios in CI. It caught a real defect the day it was written. npm run prelude:claims runs
it with the other emitted-asset checks.
For a host that must leave Steam's cold startup untouched, construct
new PersistentSteamUiTransport(requireMainWindow: true). Discovery then waits for one validated main window before
attaching to any role, while the default constructor keeps unrestricted target discovery. Pass the configured opt-in
explicitly to
SteamCef.EnsureRemoteDebuggingEnabled(directory, enabled); the flag has to be writable while the transport is
intentionally held closed.
In standalone feature scripts, use SteamUiModuleResolver.CreateExpression(scope). The returned resolver's
resolve(tokens) loads a module by a unique source fingerprint, and
exported(tokens, predicate) returns the one export of that module fitting the predicate.
count(tokens) and findUnique(tokens) inspect source without loading exports. Missing factories never enter webpack's
loader, and ambiguous or failed resolution is explicit. Feature scripts must not implement their own registry scan; the
bridge and the built-in probes use this same source.
The library is the machinery and the surfaces, and the data behind them is yours. You supply:
- a logger (
ISteamUiLog), so diagnostics land wherever your application's do; - the script you inject (
SteamUiInjectedAsset):dist/steam-ui.jsas built bynpm run prelude:build, or the same fragments compiled together with your own, since the whole thing is evaluated in one CDP call and is therefore one script; - a backend per surface you want, and a reading of its state.
Each surface's Module(...) turns those into an ISteamUiModule. Register SteamUiBridgePatch and the modules'
patches in the same manager. Synchronization uses stable patch-id order and retries unmet conditions, so registration
call order does not matter.
ISteamUiModule audio = SteamAudioSurface.Module(
enabled: () => quickAccessOn,
read: () => new(myAudio.CurrentState), // a SteamAudioState, or null to publish nothing
backend: myAudio); // an ISteamAudioBackend: default device, volumeA surface's patch id and command vocabulary are constants on it (PatchId, Commands), and the module set derives the
bridge's exact state and command vocabulary from every module you register, so pass SteamUiModuleSet.AllowedCommands
to SteamUiBridgeHost. A surface you do not register installs nothing, and its Valve UI stays exactly as the client
ships it.
A surface of your own is a fragment that calls registerGate(name, gate) plus a patch that reaches it through
window[namespace].gate(name), declared in a module like any other.
SteamUiModuleRuntime runs the two traffic directions between your modules and the client.
Which patches are on when stays yours, because that is application policy and every host's rules differ.
SteamUiPatchManager.SetGlobalEnabled and SetPatchEnabled start synchronization immediately, and their Async
counterparts wait until retraction or reapplication has finished. Use the awaited forms when shutdown, a settings
confirmation or an emergency kill switch has to know cleanup is done.
0.1.0, single consumer, moving. Two different things are unstable.
The API, because one application shaped it. The parts most likely to change are the ones that consumer does not stress: the extension host has no second implementer, and nobody has built against the module contract who did not also write it.
What Steam does, which nothing here controls. Every fingerprint token, localization token and store field is coupled to a Steam build, though far more loosely than a module id would be. The probe-first design makes a Steam update degrade to Valve's own behaviour rather than break, but compatibility is checked against a running client rather than promised by a version number.
The second will not go away at 1.0. The first should.
Extracted from WSGM, which rebuilds SteamOS Game Mode on Windows handhelds
and is where all of this was found. Its _plan/steam-ui-toolkit.md records what has been done and what has not.
MIT, see LICENSE.