prefs and vrFullscreenFix become default prefs (pref()) in defaults/pref/steam-frame-nix.js of /app/etc/firefox, the mount point of the org.mozilla.firefox.systemconfig extension point. home-manager provides the extension as a user "unmaintained extension" link ($XDG_DATA_HOME/flatpak/extension/<id>/aarch64/stable) to a store dir, which Flatpak mounts itself: the sandbox doesn't see /nix. Default prefs are never written to prefs.js, so removing one leaves nothing behind. The desktop profile undoes the fullscreen fix with a user.js linked to /app/etc/firefox/steam-frame-nix-desktop-user.js. The sync (on switch, and before each launch) removes the user.js copies and store links of older versions and takes their values out of prefs.js, for each profile not in use (Firefox holds .parentlock open).
steam-frame-nix
Home Manager modules for the
Valve Steam Frame (SteamOS, aarch64-linux, standalone home-manager). They
work around quirks of the Frame's two graphical sessions (portal, keyboard
layout, clipboard, Firefox), enable hardware video decoding in Jellyfin, and
extend Steam's and SteamVR's UIs at runtime
(VR keyboard, "+" menu, dashboard windows, Steam close button, window
curvature, window controls). Everything is declarative and reverts by
activating an older generation.
All options live under steamFrame.*. The portal fix and clipboard sync are
on by default; everything else is opt-in.
- Install · Two sessions · Usage · Options
- UI patches: finders and signatures, after a Steam update
- Fixes in detail: session, portal, keyboard layout, Steam keyboard, swipe and suggestions, launcher menu, dashboard windows, Steam close button, window curvature, window control bar, SteamVR debugger, hidden apps, clipboard sync, Firefox, Jellyfin hardware decoding
- Rollback
Install
On the Frame (or a Steam Deck), in a terminal (Konsole in desktop mode or the nested desktop):
curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- install
The short link redirects to
install.sh
on main. sudo needs a password: run passwd first if you never set one.
The installer:
- installs Nix with the NixOS nix-installer
(
steam-deckplanner: store in/home/nix, survives SteamOS updates; flakes on), unlocking the read-only root only for the install. Skipped if Nix already works; - uses
~/.config/home-manageror--flake <dir-or-flakeref>; if there is none, creates~/nix-config(a git repo, linked to~/.config/home-manager) from the template with your user name; - runs
home-manager switch; conflicting dotfiles are renamed to*.hm-backup-<time>.
Re-running it just switches again. Afterwards edit ~/nix-config/home.nix
and run home-manager switch from a terminal in the nested desktop.
curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- status # Nix, generation, services
curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- uninstall # --keep-nix keeps Nix
Uninstall stops the Home Manager user services (reverting the Steam keyboard
patch), runs home-manager uninstall, then removes Nix and per-user Nix
state. Your configuration, *.hm-backup-* files, app data and Flatpaks stay.
Manual setup: nix flake init -t github:lhns/steam-frame-nix.
Two sessions
The Frame runs two graphical sessions at once; most workarounds exist because of their differences:
| Steam / VR session | Nested Plasma desktop | |
|---|---|---|
| Compositor | gamescope | KWin (nested, shown as a VR window) |
| Displays | X display :0 (apps show as floating VR windows) |
own Wayland + Xwayland :2 |
| D-Bus | the outer session bus, /run/user/1000/bus |
a private bus |
XDG_RUNTIME_DIR |
/run/user/1000 |
its own |
| systemd user manager | yes | not reachable |
- Wallet: there should be one
kwalletd6, on the outer bus; apps started from the desktop would otherwise start a second one whose secrets VR can't see. Prefix launchers'Exec=withsteamFrame.session.busEnv. - User services: home-manager skips
reloadSystemdwhen switching from the desktop terminal, sosteamFrame.session.servicestalks to the outer user manager directly. - Launchers: the "+" menu only sees
~/.local/share/applications(not~/.nix-profile/share), so entries are written there, shadowing Flatpak/package entries with the same ID.
Usage
Requirements: Nix with flakes and standalone home-manager (see
Install). nix flake init -t github:lhns/steam-frame-nix creates
a commented version of these two files (template/):
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
steam-frame-nix = {
url = "github:lhns/steam-frame-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { nixpkgs, home-manager, steam-frame-nix, ... }: {
homeConfigurations.steamos = home-manager.lib.homeManagerConfiguration {
pkgs = nixpkgs.legacyPackages.aarch64-linux;
modules = [ steam-frame-nix.homeManagerModules.default ./home.nix ];
};
};
}
# home.nix
{ config, ... }: {
home.username = "steamos";
home.homeDirectory = "/home/steamos";
home.stateVersion = "26.05";
targets.genericLinux.enable = true;
steamFrame = {
keyboard.layout = "de";
keyboard.vr.extraKeys.enable = true;
keyboard.vr.enable = true; # swipe typing, suggestions, Backspace drag
launcherMenu = {
sort = true;
pinDesktop = "bottom";
closeOnLaunch = true;
launchDebounceSeconds = 10;
grid = { enable = true; columns = 4; maxRows = 4; };
showAllApps = true;
# Listed in the "+" menu only with showAllApps or Steam Developer Mode.
hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
};
dashboard = {
windows.maxScale = 4.0;
windows.distance.world.max = 10.0;
windows.distance.theater.max = 12.0;
steamCloseButton.enable = true;
windowCurvature.enable = true;
frameControls.enable = true;
};
firefox.enable = true;
jellyfin.hardwareDecoding.enable = true;
};
# Example: an app that must use the single wallet on the outer bus.
# xdg.dataFile."applications/org.example.App.desktop".text = ''
# [Desktop Entry]
# Type=Application
# Name=Example
# Exec=${config.steamFrame.session.busEnv} flatpak run org.example.App %U
# '';
}
Switch from a terminal in the nested desktop (so clipboard-sync restarts with the desktop's environment):
home-manager switch --flake .#steamos
Individual modules:
homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,vr-keyboard,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,steam-close-button,window-curvature,frame-controls,clipboard-sync,firefox,jellyfin};
default imports all.
Steam Developer Mode (a Steam setting, not managed here) makes the "+"
menu list every desktop entry; launcherMenu.showAllApps does the same
without it.
Options
| Option | Type | Default | Description |
|---|---|---|---|
steamFrame.session.runtimeDir |
str | "/run/user/1000" |
XDG_RUNTIME_DIR of the outer (Steam/VR) session. |
steamFrame.session.bus |
str | "unix:path=${runtimeDir}/bus" |
Outer session D-Bus (user manager, kwalletd6). |
steamFrame.session.busEnv |
str, read-only | "env DBUS_SESSION_BUS_ADDRESS=${bus}" |
Prefix for launchers that must use the outer bus. |
steamFrame.session.services.start |
list of str | [ ] |
User units started on switch if not running. |
steamFrame.session.services.restart |
list of str | [ ] |
User units restarted on every switch. |
steamFrame.session.services.stop |
list of str | [ ] |
User units stopped on switch if running (e.g. of a disabled feature). |
steamFrame.session.portalFix.enable |
bool | true |
Working portal config (OpenURI) for the Steam session. |
steamFrame.keyboard.layout |
null or str | null |
XKB layout for the Steam session, e.g. "de"; null: US. |
steamFrame.keyboard.variant |
null or str | null |
XKB variant for the Steam session, e.g. "nodeadkeys"; see Keyboard layout. |
steamFrame.keyboard.vr.extraKeys.enable |
bool | false |
VR keyboard with Esc/Ctrl/Alt, arrows, real chords, AltGr/non-ASCII. |
steamFrame.keyboard.vr.enable |
bool | false |
Swipe typing, suggestions and Backspace drag on the VR keyboard; the sub-features below are on by default, see VR keyboard. |
steamFrame.keyboard.vr.swipe.enable |
bool | true |
Swipe typing. |
steamFrame.keyboard.vr.dictionary.languages |
list of submodules | layout language + English | { language; hunspell; words; frequencyOffset; keepFrequentAbove; }: wordfreq language, pkgs.hunspellDicts name (or null), most frequent words taken, zipf offset, keep words Hunspell rejects from this zipf on (default 4.0). Default: the keyboard.layout language (de, fr, es, it, nl, pt, sv; 60000) + English (40000, -0.3), else English (60000). |
steamFrame.keyboard.vr.dictionary.contractions |
bool | true |
Words with apostrophes (couldn't, geht's), swiped by their letters. |
steamFrame.keyboard.vr.dictionary.extraWords |
list of str | [ ] |
Words always included, casing as given. |
steamFrame.keyboard.vr.dictionary.extraWordsFrequency |
number | 5.0 |
Zipf frequency of extra words. |
steamFrame.keyboard.vr.dictionary.extraWordFiles |
list of paths | [ ] |
Word lists, word or word<TAB>zipf per line. |
steamFrame.keyboard.vr.dictionary.excludeWords |
list of str | [ ] |
Words never suggested. |
steamFrame.keyboard.vr.text.bufferChars |
int | 128 |
Characters of typed text the keyboard remembers. |
steamFrame.keyboard.vr.text.resetAfterIdleSeconds |
int | 30 |
Forget it after this long without typing (0: never). |
steamFrame.keyboard.vr.text.autoSpace |
bool | true |
Space before a swiped word after a known non-space character. |
steamFrame.keyboard.vr.suggestions.position |
"below", "above", "inside" |
"above" |
Suggestion strip: SteamVR panel below/above the keyboard, or over its number row. |
steamFrame.keyboard.vr.suggestions.count |
int | 6 |
Suggestions shown. |
steamFrame.keyboard.vr.autocorrect.enable |
bool | true |
Correction suggestions for finished tapped words not in the dictionary. |
steamFrame.keyboard.vr.autocorrect.maxEditDistance |
int | 2 |
Largest edit distance (neighbouring keys and swaps count 0.5). |
steamFrame.keyboard.vr.completions.enable |
bool | true |
Completions of the tapped word. |
steamFrame.keyboard.vr.completions.minPrefix |
int | 2 |
Letters typed before completions show. |
steamFrame.keyboard.vr.backspaceDrag.enable |
bool | true |
Backspace drag: left deletes, back right retypes. |
steamFrame.keyboard.vr.backspaceDrag.pixelsPerChar |
int | 25 |
Travel per character (keyboard px; a key is ~60). |
steamFrame.keyboard.vr.backspaceDrag.wordDetentPixels |
int | 90 |
Extra travel across a word border (0: none). |
steamFrame.keyboard.vr.haptics |
bool | true |
Haptic ticks for drag steps, word detents and picks. |
steamFrame.keyboard.vr.checks |
package, read-only | The tests, built with the configured dictionary. | |
steamFrame.uiPatches.patches |
list of submodules | [ ] |
Runtime patches of Steam's web UIs, see UI patches. |
steamFrame.uiPatches.lib |
attrs, read-only | Patch helpers (mkPatch), see Finders and signatures. |
|
steamFrame.launcherMenu.sort |
bool | false |
Sort the "+" menu alphabetically. |
steamFrame.launcherMenu.pinDesktop |
null or "top" / "bottom" |
null |
Pin "Desktop" above/below the "+" menu's list; null: normal entry. |
steamFrame.launcherMenu.closeOnLaunch |
bool | false |
Close the "+" menu when a program is clicked. |
steamFrame.launcherMenu.launchDebounceSeconds |
unsigned int (s) | 0 |
Ignore repeat launches of a program within this time; 0: off. |
steamFrame.launcherMenu.grid.enable |
bool | false |
Show the "+" menu's programs as a grid of tiles. |
steamFrame.launcherMenu.grid.columns |
int, 1-8 | 4 |
Tiles per row (3 ≈ 92 px, 4 ≈ 68 px, 5 ≈ 53 px). |
steamFrame.launcherMenu.grid.maxRows |
null or positive int | null |
Visible rows, the rest scrolls; null: up to 600 px. |
steamFrame.launcherMenu.showAllApps |
bool | false |
List all programs without Developer Mode, see Launcher menu. |
steamFrame.launcherMenu.iconFallbacks.enable |
bool | true |
Link Breeze icons into hicolor for entries Steam shows without icon, see Icon fallbacks. |
steamFrame.launcherMenu.iconFallbacks.extra |
list of str | [ ] |
Extra icon names to provide. |
steamFrame.launcherMenu.hiddenApps |
list of str | [ ] |
Desktop entry ids (no .desktop) hidden from the "+" and KDE menus. |
steamFrame.dashboard.windows.maxScale |
null or positive number | null |
Max resize scale of dashboard windows; null: stock (2), see Dashboard windows. |
steamFrame.dashboard.windows.distance.{world,theater,dashboard}.{min,max} |
null or positive number (m) | null |
Pull-in / push-back limits of grabbed windows; null: stock (world 0.25-5, theater 1-6, dashboard 0.3-4 m). |
steamFrame.dashboard.steamCloseButton.enable |
bool | false |
X button on the dashboard's Steam window, see Steam close button. |
steamFrame.dashboard.windowCurvature.enable |
bool | false |
Adjustable curvature per window, see Window curvature. |
steamFrame.dashboard.windowCurvature.initial |
non-negative number | 1.0 |
Curvature of curved world/hand windows without own value (1 = stock, 0 = flat). |
steamFrame.dashboard.windowCurvature.max |
positive number | 3.0 |
Largest curvature. |
steamFrame.dashboard.windowCurvature.step |
positive number | 0.05 |
Rounding step while dragging (at most max). |
steamFrame.dashboard.windowCurvature.detentPixels |
unsigned int (px) | 24 |
Detent at each detent point in drag pixels: the value holds there, then continues (nothing skipped); 0: none. |
steamFrame.dashboard.windowCurvature.detentPoints |
list of non-negative numbers | [ 0 1.0 ] |
Detent points (flat, stock), at most max. |
steamFrame.dashboard.windowCurvature.dragThresholdPixels |
unsigned int (px) | 8 |
Vertical travel before a press becomes a drag. |
steamFrame.dashboard.windowCurvature.dragPixelsPerUnit |
positive number (px) | 120 |
Drag distance per 1.0 in the menu (6 px per 0.05 step). |
steamFrame.dashboard.windowCurvature.barDragPixelsPerUnit |
positive number (px) | 60 |
Drag distance per 1.0 on the bar button. |
steamFrame.dashboard.windowCurvature.haptics |
bool | true |
Controller haptics while dragging (steps, detents, edges); the dashboard's hover clicks are muted during a drag. |
steamFrame.dashboard.frameControls.enable |
bool | false |
Move window controls between bar and three-dot menu, see Window control bar. |
steamFrame.dashboard.frameControls.longPressMs |
int, 300-10000 (ms) | 1500 |
Long-press duration. |
steamFrame.dashboard.frameControls.inBar |
list of control names | [ ] |
Controls that start in the bar: keyboard, float, dashboard, theater, dockLeft, dockRight, close, curvature, "icon:<n>". |
steamFrame.dashboard.frameControls.inMenu |
list of control names | [ ] |
Controls that start in the three-dot menu. |
steamFrame.dashboard.frameControls.floatInTheater |
bool | false |
"Float" control on theater windows. |
steamFrame.steamvrDebugger.enable |
bool | automatic | SteamVR dashboard DevTools on 127.0.0.1:8087; on when a dashboard patch is, see SteamVR debugger. |
steamFrame.clipboardSync.enable |
bool | true |
Clipboard bridge between the Steam session and the nested desktop. |
steamFrame.clipboardSync.package |
package | built from dnut/clipboard-sync |
The clipboard-sync package. |
steamFrame.firefox.enable |
bool | false |
Launcher for the Flathub Firefox Flatpak with the fixes below. |
steamFrame.firefox.vrFullscreenFix |
bool | true |
Default full-screen-api.ignore-widgets to true (not in the desktop profile). |
steamFrame.firefox.prefs |
attrs of bool, int or str | { } |
about:config default values for every profile, e.g. { "media.av1.enabled" = false; }. |
steamFrame.firefox.desktopProfile |
null or str | "desktop" |
Separate profile for the nested desktop; null: none. |
steamFrame.jellyfin.hardwareDecoding.enable |
bool | false |
Hardware video decoding in the Jellyfin Desktop Flatpak, see Jellyfin. |
steamFrame.jellyfin.hardwareDecoding.hwdec |
str | "v4l2m2m-copy,auto-copy" |
mpv hwdec used instead of Jellyfin's automatic one. |
Renamed options still work under their old names, with a warning:
| Old | New |
|---|---|
keyboardLayout, keyboardVariant |
keyboard.layout, keyboard.variant |
steamKeyboardPatch.enable |
keyboard.vr.extraKeys.enable |
hiddenApps |
launcherMenu.hiddenApps |
launcherMenu.launchDebounce |
launcherMenu.launchDebounceSeconds |
runtimeDir, userBus, outerBusEnv |
session.runtimeDir, session.bus, session.busEnv |
userServices.{start,restart,stop} |
session.services.{start,restart,stop} |
portalFix.enable |
session.portalFix.enable |
dashboard.windowMaxScale |
dashboard.windows.maxScale |
dashboard.windowDistance.* |
dashboard.windows.distance.* |
dashboard.windowCurvature.default |
dashboard.windowCurvature.initial |
dashboard.windowCurvature.snapPixels, snapPoints |
detentPixels, detentPoints |
dashboard.windowCurvature.dragThreshold |
dragThresholdPixels |
UI patches (uiPatches.patches)
Steam's UI and SteamVR's dashboard (vrwebhelper) are CEF web pages with a
local DevTools port: 127.0.0.1:8080 for Steam (SteamOS passes
-cef-enable-debugging), 127.0.0.1:8087 for SteamVR once
its debugger is on. The
steam-ui-patches user service (injector.mjs) patches the running pages
through them; Steam's files are never modified. The launcher menu and
dashboard features are such patches, and you can add your own:
| Attribute | Default | Description |
|---|---|---|
name |
Unique name (log). | |
endpoint |
"http://127.0.0.1:8080" |
DevTools base URL; /json/list is polled every 5 s. |
target.title / target.titleRegex / target.urlRegex |
null |
Pages to patch; all given criteria must match (JS regexes). |
patch |
JS file evaluated in every matching page (awaited). | |
unpatch |
null |
JS file evaluated when the service stops. |
steamFrame.uiPatches.patches = [ {
name = "my-patch";
target.title = "SharedJSContext"; # Steam's main JS context
patch = ./my-patch/patch.js;
unpatch = ./my-patch/unpatch.js;
} ];
Patches are evaluated on attach, after new JS contexts (reloads) and every
15 s, so they must be idempotent: return e.g. "patched" once, then
"unchanged" (not logged); other results are logged when they change
(journalctl --user -u steam-ui-patches). On stop, unpatch restores the
stock UI without a Steam restart. The service exists only while the list is
non-empty and is restarted on every switch.
DevTools on the LAN: Steam's Developer Mode enables
steam-web-debug-portforward (0.0.0.0:8081 → 8080) and
steamvr-web-debug-portforward (0.0.0.0:8088 → 8087), and firewalld
allows ports 1024-65535, so anyone on the network could run code in Steam's
UI. No patch needs Developer Mode, so keep it off. If it was on while those
units were masked, they may stay enabled: check with
systemctl is-enabled steam-web-debug-portforward steamvr-web-debug-portforward
and sudo systemctl disable them.
Caveat: patches depend on Steam UI internals and can break with an update; find modules by signature, not id (below).
Finders and signatures
Webpack module ids and export names change with every Steam UI build, so
patches never use them. modules/lib/finders.js (like Decky Loader's
findModule/findInReactTree or Vencord's find) locates by signature:
- a module by strings/regexes in its factory source;
- an export by shape: type, function source, arity, data properties, prototype methods or getters;
- React fibers by props (
findFiberUp,findFiberDown,findInReactTree).
Every signature must match exactly once, otherwise the patch changes nothing
and reports it (e.g. signature not found, Steam left unpatched: layouts.currentLayout (module 40222): ambiguous export, candidates r_, xy).
Results are cached per page.
Signatures live in modules/lib/signatures.json, shared by patches and the
offline checker. An entry can also list expects (strings the patch relies
on, checked offline only) or be checkOnly (anchors not used through the
finder, checked offline only; with stylesheet instead of module it is
matched against the bundle's CSS).
steamFrame.uiPatches.lib.mkPatch wraps a patch file — a function expression
(find, sigs, opts, hooks) => … returning a status string — with the finder
library, its signatures, options and the shared hooks (details in
modules/lib/default.nix):
steamFrame.uiPatches.patches = [ {
name = "my-patch";
target.title = "SharedJSContext";
patch = config.steamFrame.uiPatches.lib.mkPatch {
name = "my-patch";
src = ./my-patch/patch.js; # ((find, sigs, opts, hooks) => { … })
signatures.thing = {
module.includes = [ "SomeUniqueString" ];
exports.Thing = { type = "class"; protoMethods = [ "DoIt" ]; };
};
opts.factor = 2;
};
unpatch = ./my-patch/unpatch.js;
} ];
((find, sigs, opts, hooks) => {
let mods;
try { mods = find.resolveAll(find.getWebpackRequire('webpackChunksteamui'), sigs); }
catch (e) { return `not patched: ${e.message}`; }
const Thing = mods.thing.exports.Thing; // SteamVR dashboard: 'webpackChunkvrwebui'
…
})
Shared method hooks (modules/lib/hooks.js, argument hooks, also
window.__sfuiHooks): patches intercepting the same method (e.g. the
dashboard mailbox's SendMessage, used by
Dashboard windows and
Window curvature) register
named hooks; one wrapper per method runs them in registration order, so
patches can be injected, upgraded and reverted in any order.
hooks.before(Mailbox.prototype, 'SendMessage', 'my-patch', (args) => {
if (args[1]?.type === 'update_scene_graph') rewrite(args[1].scene_graph);
});
hooks.remove(Mailbox.prototype, 'SendMessage', 'my-patch'); // in unpatch
After a Steam update
Check the signatures offline (Steam need not run) from a checkout:
nix shell nixpkgs#nodejs -c node scripts/check-signatures.mjs
It loads Steam's UI bundle (~/.local/share/Steam/steamui) and SteamVR's
dashboard (/opt/steamvr/resources/webinterface/dashboard/systemui.html),
evaluates every signature with the patches' finder code (exports in an inert
sandbox) and prints per patch found (module id, export name), ambiguous
or missing, plus warnings for missing expects; exit status 1 if anything
is missing or ambiguous:
bundle steamui: 2827 modules in /home/deck/.local/share/Steam/steamui (build 11041156)
steam-keyboard-patch (steamui)
layouts found module 40222 (chunk~2dcc5aaf7.js)
.currentLayout found export r_
…
OK: all signatures match exactly once
To fix a signature, inspect the new module sources
(scripts/webpack-modules.mjs), adjust signatures.json and bump the
patch's VERSION if its code changes. Flags: --signatures FILE (your own,
bundles steamui / vrwebui-systemui), --dir steamui=DIR,
--patch NAME, --strict (fail on warnings), --json. Live:
journalctl --user -u steam-keyboard-patch -u steam-ui-patches.
Fixes in detail
Session settings and background services (session.nix)
Used by the other modules; normally nothing to set.
session.runtimeDir/session.buspoint at the Steam session's runtime dir and bus (services, KDE wallet), which the nested desktop can't see;session.busEnvis a launcher prefix to reach them (Exec=${config.steamFrame.session.busEnv} flatpak run …).- Switching from the nested desktop, Home Manager can't reach the service
manager ("User systemd daemon not running"). So after every switch this
module reloads the Steam session's user manager and applies
session.services.start/stop/restart, which other modules fill.
Portal (session.portalFix)
Problem: the Frame image (SteamOS 0.3.0, build 20260922) points the Steam
session's xdg-desktop-portal at /usr/share/xdg-desktop-portal/gamescope-portals,
which lacks gamescope-portals.conf: no backend, no OpenURI, so no app in
the Steam session can open links.
Fix: a portal dir in ~/.local/share linking Valve's .portal files plus
a config (default=holo;gamescope), and a drop-in on
xdg-desktop-portal.service. The desktop's portal is unaffected.
Remove when SteamOS ships gamescope-portals.conf.
Keyboard layout (keyboard.layout, keyboard.variant)
Problem: gamescope and its Xwayland displays use US unless
XKB_DEFAULT_* is set; KDE's layout only affects the nested desktop, and
~/.config/environment.d isn't read on the Frame.
Fix: a drop-in on gamescope-session.service setting
XKB_DEFAULT_LAYOUT/VARIANT; applies at the next Steam session start.
keyboard.variant picks a variant of the layout, e.g. for de: null
(standard, with dead keys: ^, `, ´ wait for the next key),
"nodeadkeys" (those are typed immediately), "mac", "neo", "e1", "us"
(German letters on a US layout). List them with
localectl list-x11-keymap-variants <layout>.
Remove when SteamOS applies a layout setting to gamescope.
Steam keyboard patch (keyboard.vr.extraKeys.enable)
Problem: Steam's VR keyboard has no Ctrl, Alt or Esc, can't press real
keys, and its text emulation only maps plain ASCII: non-ASCII and
AltGr/dead-key characters on the German keymap (| @ { [ ] } \ ~ ^,
backtick, ä ö ü €) come out as 1.
Fix: the steam-keyboard-patch user service (helper.mjs) injects a
patch over DevTools (127.0.0.1:8080) and re-injects it after Steam
restarts; stopping it (or disabling the option) reverts the patch. No
reboot or Steam restart needed.
- Bottom row:
Esc Ctrl Alt [Space] AltGr ← ↑ ↓ → Close, stable with Shift or AltGr. - AltGr + arrows: Home, End, Page Up, Page Down (hinted on the keys); Shift + arrows select text.
- AltGr + the key left of Backspace (
´on German,=on US): Delete, labelled like Steam's Delete key in its language (Entf;Delif that is longer), hinted on the key without AltGr; repeats while held. Layouts with an AltGr character on that key get none. - Layouts without AltGr (US, Dvorak, Colemak, Bulgarian, Chinese, Japanese,
Korean) get an
Fnkey right of the space bar: Steam's AltGr toggle (tap: once, tap twice: locked, hold), for Delete and Home/End/Page Up/Down. - Ctrl/Alt chords and Esc are sent with
xdotool keyon:0(focus follows the VR-selected window); a toggled Ctrl/Alt is held down while the keyboard is open (e.g. Ctrl+scroll). - Problem characters are typed with
xdotool type; everything else goes through Steam. - Enter always types Return (stock Steam may send it to a Steam search box).
Layouts: the character routing targets the German keymap; on others it is harmless (those characters are typed by xdotool), and Esc/Ctrl/Alt/arrows work regardless.
Security: the helper only accepts single-key Ctrl/Alt chords, the extra keys, Ctrl/Alt hold/release and single non-ASCII/AltGr characters; it cannot type ASCII text or press Enter.
Caveat: found by signature (see Finders and signatures); if one stops matching, the keyboard stays stock and the journal says why (see After a Steam update). Tested with Steam client 1790377368 (UI build 11041156).
Remove when Steam's VR keyboard gets these keys.
VR keyboard: swipe and suggestions (keyboard.vr.*)
Problem: Steam's VR keyboard is tap-only: no swipe typing, no suggestions, and deleting more than a few characters means many Backspace taps.
Fix: a Steam UI patch (vr-keyboard, injected like the other UI
patches); opt in with keyboard.vr.enable.
- Swipe: press the trigger on the first letter, sweep over the others,
release on the last. The word is typed with a space before it if needed;
alternatives show in the strip. Words are matched by shape (SHARK2-style
template matching) against a dictionary built from wordfreq frequency
lists and Hunspell, both from nixpkgs;
'and-are typed, not swiped. - Suggestions never change text by themselves: a finished tapped word that isn't in the dictionary gets corrections (itself first), a word being tapped gets completions (the typed letters first). A pick replaces exactly what it typed and can be switched again.
- Backspace drag: drag Backspace left to delete one character per
pixelsPerChar, with a detent (wordDetentPixels) at each word border and at the start of what the keyboard typed; drag back right to retype. - Strip: a SteamVR dashboard panel below or above the keyboard (patch of
SteamVR's
systemui, with thevr-keyboard-relayuser service carrying the strip between the two pages), or inside the keyboard over its number row. Its buttons take the keyboard's key style. - Haptics: light ticks for drag steps and picks, a Snap at word detents.
The keyboard can't read the text field, so it remembers what it typed
itself (text.bufferChars); anything it can't follow (Enter, arrows,
extraKeys' xdotool keys, another field, text.resetAfterIdleSeconds) resets
that, and suggestions only replace text the memory proves intact. Works with
and without keyboard.vr.extraKeys (with it, non-ASCII words are typed via
its xdotool helper).
Tests: nix flake check (checks vr-keyboard: text model, corrector,
decoder accuracy on German + English) and keyboard.vr.checks for the
configured dictionary. Debugging: window.__sfuiSwipeLog and
__sfuiSwipePaths in Steam's SharedJSContext (replay swipes with
scripts/vr-keyboard-replay.mjs).
Caveat: found by signature (entries vr-keyboard, vr-keyboard-panel);
if one stops matching the keyboard stays stock. Accented words of other
languages are in the dictionary but only swipable where the layout has the
letters.
Remove when Steam's VR keyboard gets swipe typing and suggestions.
Launcher menu (launcherMenu.*)
Problem: the VR dashboard's "+" menu (non-Steam programs) is in random order with "Desktop" somewhere in a scrolling list, and a click shows no feedback until the window appears, so programs often get started twice.
Fix: UI patches in Steam's SharedJSContext:
sort: programs sorted by name (case-insensitive), Desktop included.pinDesktop = "top"/"bottom": Desktop pinned above/below the list, always visible. Limitation: the pinned copy works with the laser but not with thumbstick / D-pad navigation.closeOnLaunch: the menu closes on click.launchDebounceSeconds = <seconds>: a repeat launch of the same command within that time is ignored (and logged); a program that exits right away can only be restarted once the time is up.grid.enable: the programs section becomes a grid of tiles (icon, name below); "Add desktop window" stays a list. Only restyles Steam's items, so launching, sounds and controller navigation keep working. The popup is 300 px wide, socolumnssets the tile size (see Options);maxRowslimits visible rows, the rest scrolls.showAllApps: without Developer Mode Steam hideskonsole,systemsettings,dolphin,plasma-discover,vlc,firewall-config,cmake-gui,qrenderdoc,lxterminalandsh; this lifts that filter only, so Developer Mode (sshd, xrdp, LAN DevTools forwards) can stay off. Hide single programs withhiddenApps.
All revert when turned off (next switch). The anchors (APIs, React props, CSS) are verified by the offline checker. Tested with Steam client 1790377368.
Icon fallbacks (launcherMenu.iconFallbacks)
Problem: Steam resolves Icon= only in the hicolor theme (and
pixmaps), so Konsole and KDE System Settings, whose icons only Breeze has,
show without icon.
Fix: on every switch, a script scans the desktop entries Steam sees and,
for each icon name missing from hicolor but present in nixpkgs' Breeze app
icons, links the Breeze SVG into
~/.local/share/icons/hicolor/scalable/apps/. extra adds names regardless
of the scan. Links are tracked in
~/.local/state/steam-frame-nix/icon-fallbacks; stale ones are removed,
enable = false removes all, and no other file is touched. A running Steam
picks them up (the script bumps the icon dir's mtime). New programs get
their fallback on the next switch.
iconFallbacks used to be a list; a list now fails with a hint (use
extra, or enable = false for [ ]).
Dashboard windows (dashboard.windows.*)
Problem: SteamVR dashboard windows can only be enlarged to 2x, and grabbed windows pushed back only to 5 m (6 m in theater), too close for a big screen.
Fix: a UI patch of SteamVR's dashboard (turns on the SteamVR debugger) raises these limits, which the dashboard sends to the compositor in its scene graph:
| Option | Stock |
|---|---|
maxScale |
2 (relative to the window's default size; the theater screen's default is 2.8x larger) |
distance.world.{min,max} |
0.25-5 m |
distance.theater.{min,max} |
1-6 m |
distance.dashboard.{min,max} |
0.3-4 m |
Distances limit pulling in / pushing back a grabbed window (thumbstick or
scroll while dragging); null keeps stock. Changes apply immediately, and
turning options off reverts on the next switch. The keyboard's range is not
patched.
steamFrame.dashboard.windows = {
maxScale = 4.0; # resize up to 4x (theater: 11.2x)
distance.world.max = 10.0; # push windows back up to 10 m
distance.theater.max = 12.0;
};
Caveats: found by signature (dashboard-windows in signatures.json);
on mismatch the dashboard stays stock. Grab nodes are recognized by their
exact stock values, so if SteamVR changes them the distance options silently
do nothing. State: window.__sfuiDashboardWindows in the systemui page.
Steam close button (dashboard.steamCloseButton.enable)
Problem: every dashboard window has a close (X) button except Steam's own, and with no other window open the dashboard always shows it.
Fix: a UI patch of SteamVR's dashboard (turns on the SteamVR debugger) gives the Steam window an X that hides Steam: it docks the window back if it was in the world, theater or on a hand, then shows the most recently active other dashboard window, or just the dashboard bar if there is none.
Steam stays hidden until you bring it back (Steam tab, a Steam menu pick,
SteamVR asking for it): closing the active window, or a theater window,
then goes to the previous window or the bar instead of Steam. This
survives dashboard reopens and patch-service restarts; a SteamVR restart
starts with Steam. Debugging: window.__sfuiSteamClose.plan() /
homePlan() and window.__sfuiSteamCloseState in the systemui page.
Limitations: SteamVR's rarer "go home" paths (Now Playing after a game quits, message overlays) still show Steam; no effect with a VRLink remote dashboard; turning the option off while bar-only leaves no active window until the next tab click or dashboard open.
Caveats: found by signature (steam-close-button in signatures.json);
on mismatch the dashboard stays stock. Tested with SteamVR build 11008059.
Window curvature (dashboard.windowCurvature.*)
Problem: SteamVR dashboard windows are either curved (fixed radius) or flat, and world windows start flat.
Fix: a UI patch of SteamVR's dashboard (turns on the SteamVR debugger) turns the "Toggle Curvature" row of a window's three-dot menu into a control showing the window's value; the same control in the bottom bar (see window control bar) works without the value, with haptic steps.
- click: curved → flat, flat → stock (1);
- drag up/down with the laser: curvature from 0 (flat) to
max, relative to stock (2 = half the radius), with a detent ofdetentPixelsof drag at each ofdetentPoints(no values skipped), and haptics for detents, edges and steps.
A window without its own value is shown at initial once curved in the
world or on a hand, at 1 in the dashboard or theater. Values are kept per
window until SteamVR restarts. Debugging: window.__sfuiWindowCurvature.dump()
(.log recent events).
Limitations:
- Laser only; with gamepad navigation the row is the stock toggle.
- No thumbstick scrolling (SteamVR sends no wheel events to the menu).
- The laser stops at the menu's edge (~190 px above the row): with the
default 120 px per 1.0, 0 → 1 fits into one drag, 0 → 3 takes two. Lower
dragPixelsPerUnit(≤ 60) for the full range in one drag.
For patch authors (other patches handling presses on these controls):
every element the patch drives has class sfui-curv-ctl; when a press
becomes a drag, a bubbling CustomEvent sfui-curv-dragstart (detail
{ frameID, where: 'menu' | 'bar' }) is dispatched on it, and
sfui-curv-dragend when it ends;
window.__sfuiWindowCurvature.scalePressDragThreshold(factor) sets the
current press's drag threshold to factor × dragThresholdPixels (from the
press start; returns whether it applied, i.e. a press that is not yet a
drag); window.__sfuiWindowCurvature.cancelPress() ends a press without its
click. A patch with its own gesture lets mousemove through while
undecided, drops its gesture on sfui-curv-dragstart, may raise the
threshold while its gesture is under way, and calls cancelPress() when it
takes the press over.
Caveats: found by signature (window-curvature in signatures.json);
on mismatch the dashboard stays stock. Tested with SteamVR build 11008059.
Window control bar (dashboard.frameControls.*)
Problem: the controls under a dashboard window are fixed: some in the bottom bar, others only in the three-dot menu (curvature, dock to a controller), and theater windows have no "Float".
Fix: a UI patch of SteamVR's dashboard (turns on the SteamVR debugger):
- long press a bar icon or menu row (
longPressMs; a progress ring shows from half the time, at most after 1 s), then Show in bar in the popup moves that control between bar and menu for all windows. Placements survive SteamVR restarts. inBar/inMenuset where controls start; a popup choice wins until that control's entry changes.floatInTheatergives theater windows the "Float" control.
steamFrame.dashboard.frameControls = {
enable = true;
# longPressMs = 1500;
# inBar = [ "curvature" ]; inMenu = [ "theater" ];
# floatInTheater = true;
};
With window curvature, a drag
on the curvature control adjusts curvature and cancels the long press, also
after the ring shows; once the ring shows, the drag needs 3× the usual travel
(dragThresholdPixels, counted from where the press started), so laser
drift during the hold doesn't cancel it. Debugging: window.__sfuiFrameControls.dump(), .placement(),
.reset() (forget choices), .log.
Limitations: laser only (no right-click or thumbstick click reaches the dashboard; gamepad navigation sees stock controls); placements are per control type, not per window.
Caveats: found by signature (frame-controls in signatures.json); on
mismatch the dashboard stays stock. Tested with SteamVR build 11008059.
SteamVR debugger (steamvrDebugger.enable)
Dashboard patches need SteamVR's DevTools port, opened only with
VRWebHelper/DebuggerEnabled (port VRWebHelper/DebuggerPort, default
8087). It is enabled automatically when any patch in
steamFrame.uiPatches.patches uses port 8087.
SteamVR rewrites ~/.config/openvr/config/steamvr.vrsettings itself, so a
oneshot merges just that key (with jq) before every SteamVR start.
The first time, restart SteamVR once (e.g. reboot); until then
steam-ui-patches keeps polling. Disabling resets the key at the next
SteamVR start, but only if this module set it.
Security: the port listens on 127.0.0.1 only; keep Developer Mode off
(see "DevTools on the LAN" in UI patches).
Hidden apps (launcherMenu.hiddenApps)
Problem: with Developer Mode or launcherMenu.showAllApps, the "+" menu
lists every desktop entry, including system tools.
Fix: a user entry with Hidden=true in ~/.local/share/applications
masks the system one (also in the KDE menu). The "+" menu always hides
steam and vrurlhandler; for Konsole in VR use
launcherMenu.showAllApps.
Clipboard sync (clipboardSync.enable)
Problem: the Steam session's X displays and the nested desktop have separate clipboards.
Fix: clipboard-sync, built from
source (its flake is x86-only), started via KDE autostart (the desktop can't
reach the user manager, and :2 must exist first). Each switch restarts it
if outdated, so switch from a desktop terminal.
Firefox (firefox.*)
For the Flathub Firefox Flatpak (org.mozilla.firefox, stable). The
launcher shadows the Flatpak's own entry (same ID), so default-browser
associations keep working.
vrFullscreenFix: gamescope never shows fullscreen windows, so Firefox looks frozen.full-screen-api.ignore-widgetsmakes fullscreen fill just the window. Remove when gamescope shows fullscreen X11 windows in VR.prefs:about:configvalues for every profile. E.g."media.av1.enabled" = false: the Frame's decoder has no AV1, so YouTube and co. fall back to VP9/H.264, which it decodes in hardware.desktopProfile: the sessions can't see each other's Firefox, so a second instance stops at the locked profile; in the nested desktop the launcher uses this separate profile.
prefs and the fullscreen fix are default values (pref()), not user
values: about:config can still change them per profile, and nothing is
written to prefs.js, so removing one leaves nothing behind. Firefox reads
default prefs from defaults/pref/*.js in its system config dir, which in
the Flatpak is /app/etc/firefox, the mount point of the
org.mozilla.firefox.systemconfig extension. home-manager provides that
extension as a link from
~/.local/share/flatpak/extension/org.mozilla.firefox.systemconfig/aarch64/stable
to a store directory; Flatpak mounts it itself, so the sandbox doesn't get
/nix. (A systemconfig extension of your own would conflict with it.) The
desktop profile undoes the fullscreen fix with a user.js linked to
/app/etc/firefox/steam-frame-nix-desktop-user.js (a sandbox path).
Changes take effect at the next start of Firefox.
Older versions copied a user.js into every profile; the copies and the
values they left in prefs.js are removed on switch and before each launch,
for each profile not in use at that moment. user.js files of your own are
left alone.
Jellyfin hardware decoding (jellyfin.hardwareDecoding.*)
For the Flathub Jellyfin Desktop
Flatpak (org.jellyfin.JellyfinDesktop), which plays video with libmpv.
Problem: video is decoded in software (1080p H.264: ~40 % CPU). The
Frame's hardware decoder is a V4L2 memory-to-memory device (qcom-iris,
/dev/video*), which
- the Flatpak can't open: its
devices=dricovers only the GPU, and Flatpak has nothing between that anddevices=all; - mpv never tries: Jellyfin hard-sets
hwdec=auto-copy, and mpv'sautoprobing leaves out V4L2 M2M on purpose (its quality varies by SoC). Jellyfin has no way to pass mpv options.
Fix: a Flatpak override with devices=all, and an LD_PRELOAD shim that
rewrites an hwdec value starting with auto (set through libmpv's
mpv_set_* functions) to $SFN_MPV_HWDEC (hwdec, set in the override);
explicit values such as no stay. mpv tries the listed decoders in order and
falls back to software decoding per stream. With the default, 1080p H.264
plays through v4l2m2m-copy at ~15-20 % CPU. The shim (a few libmpv
wrappers, only libc) is preloaded from the Nix store: the override exposes
its store path (only that one, read-only) to the sandbox. It would work for
any libmpv app that sets hwdec=auto*, but only Jellyfin Desktop is set up
here.
The override goes through nix-flatpak's services.flatpak.overrides if
nix-flatpak is imported and
enabled (merged with your own overrides there). Without it, home-manager
owns ~/.local/share/flatpak/overrides/org.jellyfin.JellyfinDesktop (a link
to the store): overrides of your own for this app belong in Nix then;
flatpak override --user org.jellyfin.JellyfinDesktop ... changes are
replaced on the next switch.
Changes take effect at the next start of Jellyfin. Log
(flatpak run org.jellyfin.JellyfinDesktop in a terminal):
mpv-hwdec-shim: hwdec "auto-copy" -> "v4l2m2m-copy,auto-copy", then mpv's
Using hardware decoding (v4l2m2m-copy).
Installing the Flatpak is up to you, e.g.
flatpak install --user flathub org.jellyfin.JellyfinDesktop, or with
nix-flatpak:
services.flatpak.packages = [ "org.jellyfin.JellyfinDesktop" ];
Caveats:
devices=allgives the app all of/dev(cameras, input devices, ...), not just the decoder.- V4L2 M2M decoding quality varies with drivers and codecs. Tested: 8-bit
H.264; 10-bit HEVC is untested. mpv falls back to software only when the
decoder fails; for streams that decode with artifacts, disable the option
(or set
hwdec = "auto-copy", Jellyfin's own value).
Rollback
home-manager generations lists previous generations; run
<store path>/activate of the one you want.