Controller Focus in Desktop Mode¶
In Desktop Mode, Steam's 64-bit client loads the wrong controller layout for a game a Flatpak draws: the Desktop layout, under which the Deck's controls act as mouse and keyboard. The focus watcher in Tender's backend works around this. It pins the layout of a game's window Steam cannot attribute itself while that window has focus, and hands the choice back to Steam once focus leaves it. This page is how it does that and why. It is a stopgap: the watcher goes once Steam attributes such windows itself, and this page goes with it. What a user sees, and how to switch the watcher off, is in Troubleshooting.
Why Steam gets it wrong¶
Steam picks a controller layout by the focused window. It finds the window's process through the X server's X-Resource
extension, which answers with the pid in the host's process table, and then matches that process to a game. It matches
either a process it tracks itself, or a SteamGameId= variable in the process's /proc/<pid>/environ. A Flatpak's
processes are never tracked: the sandbox drops LD_PRELOAD, so Steam's overlay library, which is what reports a
process, is never loaded there. For a Flatpak that leaves the environment variable.
The variable is there. The 32-bit client runs on the host and reads it. The 64-bit client runs inside its own
pressure-vessel container, in a user namespace of its own, and from there another process's environ is unreadable
(EPERM). The match fails without a log line, and the window gets the Desktop layout.
Gamescope does not ask for the environment at all. It walks the window's process up its parents to the reaper Steam
started the game under, which is why Gaming Mode is not affected.
Valve tracks the client side in its steam-for-linux tracker as issue 13053.
What Steam is told: three ids, and the release¶
Each URL below was measured on a Deck under the 64-bit client. steam://forceinputappid/<id> makes Steam load the
layout of <id> and keep it until it is replaced. This is the pin.
| id | Layout | Takes effect |
|---|---|---|
| a game's | that game's | at once |
769 |
Big Picture's (basicui_neptune.vdf) |
at once |
413080 |
the desktop's | at once |
0 |
none: Steam chooses by itself again | at the next focus change |
A pin outlives its game. While one stands, Steam forces its layout onto every window, Big Picture and other games
included, until another id replaces it. A pin on a native game's window also keeps the game's in-game overlay from
taking the controller: the overlay is drawn inside the game's own window, so opening it is no X focus change, and
Steam's own switch to the overlay's layout does not get past the pin. Measured on a Deck under the 64-bit client: with a
native game's app id pinned its overlay did not take the controller; after that app id and then 0, the game and its
overlay both took it, and Steam switched to the overlay's layout and back by itself.
So the watcher pins only where Steam cannot attribute the window itself, and otherwise leaves Steam to its own choice
(domain/controller_focus.py::answer_focus):
- A window to pin — its process, or one of its parents, is a
reaperof this user started bySteamLaunchwithAppId=<id>among its own options, before the--, and the window carries noSTEAM_GAMEproperty — gets<id>on every focus change onto it, an unchanged one included, and the pin stands. Measured: RetroArch's window from RetroDECK's Flatpak carries noSTEAM_GAME; a native game's window carries its app id there, set by Steam. The process comes from X-Resource, the host's pid._NET_WM_PIDis never used, because for a Flatpak it is the sandbox's own pid. - Focus leaving a standing pin, for any other window, gets once the id Steam's own choice would be, and right after
it
0(steam_choice_for): the window'sSTEAM_GAMEfor a game Steam attributes;769for a window carryingSTEAM_GAME=769and aSTEAM_INPUT_FOCUSproperty, which is Big Picture;413080for everything else. The desktop client's windows (library, friends list, a properties dialog) carrySTEAM_GAME=769too, but notSTEAM_INPUT_FOCUS, and get413080. The title is never used, because Steam translates it. "Everything else" covers no focused window, which is also what X reports while a native Wayland window has focus — none, or a placeholder X window of the compositor's — a window gone before it could be asked about, and a window whose pid the server cannot give. The id before the0sets the window focused now at once; the0leaves every later change to Steam. - With no pin standing nothing is sent: not on an ordinary focus change, not for a native game, and not while no game Steam cannot attribute is running. The one exception is the first answer in a Steam run, which treats a pin as standing, because one a backend that did not stop cleanly left may still be there (When it acts).
A controller that disconnects and reconnects keeps the pin, so nothing is sent for that.
When it acts¶
Only in Desktop Mode under the 64-bit client (watcher_applies). Both are read off the running Steam:
- Which Steam: the pid in
~/.steam/steam.pid. It counts only where/proc/<pid>/commissteamand the process's executable link, start time and environment can all be read. A stale file, a pid that is some other program now, and a Steam installed as a Flatpak, whose pid file lies elsewhere and names a sandbox pid, all mean no Steam. - Which client: the directory the executable resolves into is
steamrt64. The opt-in marker file is not read, because Steam no longer needs it to switch. - Gaming Mode:
GAMESCOPE_WAYLAND_DISPLAYis in Steam's own environment, or in that of a process above it in the backend's user namespace. SteamOS'sgamescope-sessionwrites it intogamescope-environment, andsteam-launcher.serviceloads that file into the host process it starts; whether the 64-bit client's container passes it on to Steam inside is not measured, so the host processes are asked too. The environment file and gamescope's sockets stay on disk in Desktop Mode, so their presence says nothing, and neither does-gamepaduion the command line.
A Steam run is identified by its pid together with the start time in /proc/<pid>/stat, so a reused pid counts as a new
run. Steam is read every three seconds. A new run, a switch of client, Steam gone and a lost X connection are each
noticed within that time. A run where the watcher applies gets a fresh connection, and its first answer goes out at
once: a focused window to pin is pinned, and any other window gets Steam's own choice and then 0, because a backend
that did not stop cleanly may have left a pin in that run, and a new backend knows nothing of it. Where no pin stands,
that pair changes nothing in Steam. After the first answer, nothing is sent while no pin stands. The cleanup is per
Steam run: a lost X connection opened again in the same run sends no second pair. Under the 32-bit client and in Gaming
Mode nothing is opened and nothing is sent, so the attribution that works there is never overridden.
Talking to the X server¶
Steam learns about focus only through X, so the watcher asks X too. On a Wayland session it asks Xwayland. The root
window's _NET_ACTIVE_WINDOW names the focused window, and a PropertyNotify on it marks the change. The backend's
socket to the X server is watched with loop.add_reader, with no thread of its own. Each question, and the walk up
/proc, runs off the loop.
- Through libxcb, over
ctypes. It uses the system'slibxcb.so.1andlibxcb-res.so.0: no dependency added, nothing vendored. Xlib was not chosen, because its error handlers end the process and its authority lookup is global to the process.xpropandxdotoolcannot ask X-Resource. The libraries are loaded on the first connection and never at import, so a missing one costs the watcher and not the backend. Some distributions shiplibxcb-res.so.0in a package of its own; which, and how to install it, is in Troubleshooting. - The display and its cookie come from Steam's session, never from the backend's environment. The backend's systemd
unit starts before the desktop session, and its environment outlives a switch between modes.
DISPLAYandXAUTHORITYare read from the first process at or above Steam that shares the backend's user namespace and names both a display and an authority file the backend can read (~/.XauthoritywhereXAUTHORITYis unset). The second condition is not a nicety. Measured on a Deck, the chain above the 64-bit client issteamandpv-adverbinside the container, thensrt-bwrapon the host, still naming the container's/run/pressure-vessel/Xauthority, which does not exist on the host;srt-client-contabove it names the session's file. The cookie,MIT-MAGIC-COOKIE-1, is read out of that file and handed toxcb_connect_to_display_with_auth_info. The backend's own environment is never changed, and the cookie is never logged. - A reply can bring events with it. An event the server sends while the watcher waits for a reply is read off the
socket with the reply and kept in xcb's queue, so the socket never becomes readable for it. Reading the events returns
the queued ones first, so after every send that went out the watcher makes one more pass over the events
(
services/controller_focus.py,_answer_the_focus).
Where the libraries cannot be loaded, or the display cannot be reached, the watcher says so once for that Steam run and tries again at the next reading of Steam.
Handing Steam the URL¶
The URL goes over the backend's existing attachment to Steam's debugger, the one the
panel is loaded through. It is evaluated as SteamClient.URL.ExecuteSteamURL("<url>") in
SharedJSContext. The URL is built only from an integer and reaches the expression only as a JSON string. Measured on a
Deck under the 64-bit client, 413080 and 769 sent this way take effect at once, the same as from the command line.
Two other ways in were rejected:
- The
steamcommand. On SteamOS it is a wrapper script that checks runtimes, may rewrite the beta branch on every call, and starts Steam when Steam is not running. ~/.steam/steam.pipe. Its format is undocumented.
A URL that cannot go out answers False: nothing is attached yet, the connection was lost, the page threw (Steam's
SteamClient not there yet, for one), or the injector stopped for good, after
the crash watchdog or a press on
the load-failure card ended it. The pin standing stays as it was, and on
its next reading of Steam the watcher asks again what the window focused then needs, and says once that it could not. A
focus change that arrives while a send is in flight is not queued, because the next pass reads the window focused by
then. With TENDER_INJECT=off there is no attachment, so the entry point does not start the watcher at all.
At stop: the one step before the detach¶
The host lets go of Steam's debugger before it shuts the application down. A 0 sent from the services' shutdown would
find no connection, and a pin left standing keeps forcing its layout onto every window until something replaces it. So
the host gives the application one narrow step before it detaches (BackendBuild.before_detach). In that step the
watcher, and nothing else, sends 0, and only where a pin stands and the Steam it stands in is still running. Every
send after it is dropped. The rest of the stop keeps its old order.
Moving the whole detach behind the services' shutdown was rejected: the connection would stay open while every service stops, and nothing has examined that. A backend that is killed outright never runs this step, and its pin stands until the next backend's first answer in that Steam run clears it (When it acts).
Switching it off¶
TENDER_FOCUS_WATCHER=off (any case, surrounding space ignored) is read once at start-up, and so is
TENDER_INJECT=off. With either one, the watcher opens nothing, reads nothing and sends nothing, not even a 0. Any
other value leaves it on. The switch exists to check whether a game starts cleanly without the watcher, before the
watcher is removed. It has no setting in the panel.
What it does not cover¶
- Steam's game menu and overlay. In Desktop Mode, for a game in a Flatpak, the Steam button opens only Steam's main menu, under either client. The game menu follows the in-game overlay, and the overlay cannot work inside a Flatpak: it reports the sandbox's pid, which Steam checks against the host's processes. A pin fixes the controller layout and nothing about which game Steam thinks has focus.
- The on-screen keyboard. Steam+X opens none over such a game in Desktop Mode, whether the game is pinned or not.
- A native Wayland window. It is invisible to X, so the watcher cannot tell it from no window: focus leaving a pin for it gets the desktop's id, and with no pin standing it gets nothing from the watcher. Tender starts RetroDECK without a Wayland socket so that its windows are X windows (Troubleshooting).
- A Steam installed as a Flatpak. The watcher stays idle there.