Files
lhns--steam-frame-nix/README.md
T
Pierre Kisters 924ac6b82f README: changes outside Nix, rollback and uninstall
- Intro: no longer claims that everything reverts by activating an older
  generation; points to the exceptions and steam-frame-nix-cleanup.
- "Changes outside Nix (exceptions)": path, feature, lifetime, removed by
  (the SteamVR debugger key while SteamVR runs, its .armed file, the
  runtime drop-in and restore script, the dashboard patches' saved state,
  hicolor's mtime); what steam-frame-nix-cleanup does and what of older
  versions it removes; what exists only while running (UI patches,
  clipboard-sync, the Firefox desktop user.js, Jellyfin's flatpak run
  options); what install.sh sets up; app data that stays yours.
- Rollback: `cleanup --all` (curl or nix run) after rolling back to a
  generation without steam-frame-nix or older than the cleanup.
- Uninstall: what install.sh uninstall does; dropping steam-frame-nix from
  a kept configuration (cleanup --all first, or `uninstall = true;`).
- Per-module notes and options: icon fallbacks, SteamVR debugger, Firefox,
  Jellyfin; template comment for iconFallbacks.
2026-09-29 00:10:11 +02:00

1074 lines
57 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# steam-frame-nix
[Home Manager](https://github.com/nix-community/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: files are links into
the Nix store, UI patches live in memory. The few things that have to be
written elsewhere at runtime are listed under
[Changes outside Nix](#changes-outside-nix-exceptions), with their lifetime
and what removes them; `steam-frame-nix-cleanup` removes every one of them
(on each switch what the configuration no longer uses, `--all` for
everything).
All options live under `steamFrame.*`. The portal fix and clipboard sync are
on by default; everything else is opt-in.
- [Install](#install) · [Two sessions](#two-sessions) · [Usage](#usage) ·
[Options](#options)
- [UI patches](#ui-patches-uipatchespatches):
[finders and signatures](#finders-and-signatures),
[after a Steam update](#after-a-steam-update)
- [Fixes in detail](#fixes-in-detail):
[session](#session-settings-and-background-services-sessionnix),
[portal](#portal-sessionportalfix),
[keyboard layout](#keyboard-layout-keyboardlayout-keyboardvariant),
[Steam keyboard](#steam-keyboard-patch-keyboardvrextrakeysenable),
[swipe and suggestions](#vr-keyboard-swipe-and-suggestions-keyboardvr),
[launcher menu](#launcher-menu-launchermenu),
[dashboard windows](#dashboard-windows-dashboardwindows),
[Steam close button](#steam-close-button-dashboardsteamclosebuttonenable),
[window curvature](#window-curvature-dashboardwindowcurvature),
[window control bar](#window-control-bar-dashboardframecontrols),
[SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable),
[hidden apps](#hidden-apps-launchermenuhiddenapps),
[clipboard sync](#clipboard-sync-clipboardsyncenable),
[Firefox](#firefox-firefox),
[Jellyfin hardware decoding](#jellyfin-hardware-decoding-jellyfinhardwaredecoding)
- [Changes outside Nix](#changes-outside-nix-exceptions) ·
[Rollback](#rollback) · [Uninstall](#uninstall)
## Install
On the Frame (or a Steam Deck), in a terminal (Konsole in desktop mode or the
nested desktop):
```sh
curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- install
```
The short link redirects to
[`install.sh`](https://raw.githubusercontent.com/lhns/steam-frame-nix/main/install.sh)
on `main`. `sudo` needs a password: run `passwd` first if you never set one.
The installer:
1. installs Nix with the [NixOS nix-installer](https://github.com/NixOS/nix-installer)
(`steam-deck` planner: store in `/home/nix`, survives SteamOS updates;
flakes on), unlocking the read-only root only for the install. Skipped if
Nix already works;
2. uses `~/.config/home-manager` or `--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;
3. 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.
```sh
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
```
See [Uninstall](#uninstall) for what `uninstall` removes and keeps.
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=` with `steamFrame.session.busEnv`.
- **User services:** home-manager skips `reloadSystemd` when switching from
the desktop terminal, so `steamFrame.session.services` talks 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](#install)). `nix flake init -t github:lhns/steam-frame-nix` creates
a commented version of these two files ([`template/`](template)):
```nix
# 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 ];
};
};
}
```
```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;
};
# 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):
```sh
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,cleanup,dashboard-windows,steam-close-button,window-curvature,frame-controls,clipboard-sync,firefox,jellyfin}`;
`default` imports all. Every module imports `cleanup` (see
[Changes outside Nix](#changes-outside-nix-exceptions)).
**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](#keyboard-layout-keyboardlayout-keyboardvariant). |
| `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](#vr-keyboard-swipe-and-suggestions-keyboardvr). |
| `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](#ui-patches-uipatchespatches). |
| `steamFrame.uiPatches.lib` | attrs, read-only | | Patch helpers (`mkPatch`), see [Finders and signatures](#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](#launcher-menu-launchermenu). |
| `steamFrame.launcherMenu.iconFallbacks.enable` | bool | `true` | Breeze icons of Konsole and KDE System Settings in hicolor, so the "+" menu shows them, see [Icon fallbacks](#icon-fallbacks-launchermenuiconfallbacks). |
| `steamFrame.launcherMenu.iconFallbacks.extra` | list of str | `[ ]` | Further Breeze app icon names to provide (a name Breeze lacks fails the build). |
| `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](#dashboard-windows-dashboardwindows). |
| `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](#steam-close-button-dashboardsteamclosebuttonenable). |
| `steamFrame.dashboard.windowCurvature.enable` | bool | `false` | Adjustable curvature per window, see [Window curvature](#window-curvature-dashboardwindowcurvature). |
| `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](#window-control-bar-dashboardframecontrols). |
| `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` (set only while SteamVR runs); on when a dashboard patch is, see [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable). |
| `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.disableAv1` | bool | `false` | Default `media.av1.enabled` to `false`: the Frame's decoder driver has no AV1, so sites send VP9/H.264, decoded in hardware. |
| `steamFrame.firefox.prefs` | attrs of bool, int or str | `{ }` | Further `about:config` default values for every profile (override the fixes too). |
| `steamFrame.firefox.desktopProfile` | null or str | `"desktop"` | Separate profile (directory name) for the nested desktop; `null`: the default profile in both sessions. |
| `steamFrame.jellyfin.hardwareDecoding.enable` | bool | `false` | Hardware video decoding in the Jellyfin Desktop Flatpak, see [Jellyfin](#jellyfin-hardware-decoding-jellyfinhardwaredecoding). |
| `steamFrame.jellyfin.hardwareDecoding.hwdec` | str | `"v4l2m2m-copy,auto-copy"` | mpv `hwdec` used instead of Jellyfin's automatic one. |
| `steamFrame.jellyfin.hardwareDecoding.command` | str, read-only | | The `flatpak run …` command line of the desktop entry, for a terminal. |
| `steamFrame.cleanup.package` | package, read-only | | `steam-frame-nix-cleanup` (on `PATH` too), see [Changes outside Nix](#changes-outside-nix-exceptions). |
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](#steamvr-debugger-steamvrdebuggerenable) 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. |
| `state` | `false` | Give the patch one persistent JSON value (below). |
```nix
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.
**Persistent state (`state = true`):** a patch's choices made in the UI
can't be kept in the page's own storage: SteamOS's `steamvr.service` runs
`rm -rf ~/.cache/SteamVR` (vrwebhelper's browser profile, incl.
localStorage) on every SteamVR start. So the service keeps one JSON value
per such patch in `~/.local/state/steam-frame-nix/ui-patches/<name>.json`
(`$XDG_STATE_HOME`): before each evaluation it defines
`window.__sfuiStore.get(name)` / `.set(name, value)` in the page and fills
`get` from the file only while the page has no value yet (a fresh page after
a SteamVR restart, reboot or reload). `set` goes through the DevTools
binding `window.__sfuiStoreSave`; the service writes the file atomically,
only on change, only for that page's `state` patches, at most 64 KiB. Used
by the [window control bar](#window-control-bar-dashboardframecontrols)
and the [Steam close button](#steam-close-button-dashboardsteamclosebuttonenable).
The file is user data, not generated by Nix: it is kept when the patch is
disabled or removed (the choices come back when you enable it again) and
removed only by `steam-frame-nix-cleanup --all` or `install.sh uninstall`;
see [Changes outside Nix](#changes-outside-nix-exceptions).
**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`):
```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;
} ];
```
```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](#dashboard-windows-dashboardwindows) and
[Window curvature](#window-curvature-dashboardwindowcurvature)) register
named hooks; one wrapper per method runs them in registration order, so
patches can be injected, upgraded and reverted in any order.
```js
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:
```sh
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.bus` point at the Steam session's runtime
dir and bus (services, KDE wallet), which the nested desktop can't see;
`session.busEnv` is 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`; `Del` if 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 `Fn` key 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 key` on `: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](#finders-and-signatures)); if one stops matching,
the keyboard stays stock and the journal says why (see
[After a Steam update](#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 the `vr-keyboard-relay` user 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, so `columns` sets the tile size (see [Options](#options));
`maxRows` limits visible rows, the rest scrolls.
- `showAllApps`: without Developer Mode Steam hides `konsole`,
`systemsettings`, `dolphin`, `plasma-discover`, `vlc`, `firewall-config`,
`cmake-gui`, `qrenderdoc`, `lxterminal` and `sh`; this lifts that filter
only, so Developer Mode (sshd, xrdp, LAN DevTools forwards) can stay off.
Hide single programs with [`hiddenApps`](#hidden-apps-launchermenuhiddenapps).
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:** Home Manager links nixpkgs' Breeze SVGs of `utilities-terminal`
and `preferences-system` (plus `extra`) into
`~/.local/share/icons/hicolor/scalable/apps/`; a name Breeze doesn't have
fails the build, `enable = false` provides none. When the set of links
changes, the switch bumps the mtime of `~/.local/share/icons/hicolor`, so a
running Steam rescans (GTK only rereads a theme whose directory changed).
Each switch also prints hints: icons of programs Steam can't find that
Breeze has (add them to `extra`), and fallbacks hicolor has anyway.
Until 2026-09 a script made these links on switch and listed them in
`~/.local/state/steam-frame-nix/icon-fallbacks`; the first switch replaces
them with Home Manager's and `steam-frame-nix-cleanup` removes the rest.
`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](#steamvr-debugger-steamvrdebuggerenable)) 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.
```nix
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](#steamvr-debugger-steamvrdebuggerenable)) 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, patch-service restarts, SteamVR restarts and
reboots ("Steam hidden" is saved in
`~/.local/state/steam-frame-nix/ui-patches/steam-close-button.json`, see
[persistent state](#ui-patches-uipatchespatches)). After a restart the
patch attaches a few seconds after the dashboard appears, possibly after
SteamVR has already shown Steam: if Steam is (or first becomes) the active
window then, it is hidden once like with X (only the bar at that point);
otherwise it just stays hidden. 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](#steamvr-debugger-steamvrdebuggerenable)) 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](#window-control-bar-dashboardframecontrols)) 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 of `detentPixels`
of drag at each of `detentPoints` (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](#steamvr-debugger-steamvrdebuggerenable)):
- **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 and reboots (saved in
`~/.local/state/steam-frame-nix/ui-patches/frame-controls.json`, see
[persistent state](#ui-patches-uipatchespatches)).
- `inBar` / `inMenu` set where controls start; a popup choice wins until
that control's entry changes.
- `floatInTheater` gives theater windows the "Float" control.
```nix
steamFrame.dashboard.frameControls = {
enable = true;
# longPressMs = 1500;
# inBar = [ "curvature" ]; inMenu = [ "theater" ];
# floatInTheater = true;
};
```
With [window curvature](#window-curvature-dashboardwindowcurvature), 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`, `window.__sfuiFrameControlsState`.
**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` from memory,
so the key can't be a link and can't be edited while SteamVR runs. It is
set only while SteamVR runs:
- before every SteamVR start, the `steamvr-webhelper-debugger` oneshot
(a drop-in on `steamvr.service`) stores the key's value in
`~/.local/state/steam-frame-nix/steamvr-debugger.armed`, sets the key
(with `jq`) and writes a runtime drop-in,
`/run/user/1000/systemd/user/steamvr.service.d/50-steam-frame-nix-debugger.conf`,
whose `ExecStopPost=` runs a restore script next to it
(`/run/user/1000/steam-frame-nix/`, `/usr/bin` tools only);
- when SteamVR stops, the key goes back to its previous value (removed if
it wasn't there) and `.armed` is removed.
The runtime files don't need Nix, so this also works after a rollback or
uninstall; they are gone at reboot. A key you set to `true` yourself is
never touched. **The first time, restart SteamVR once** (e.g. reboot);
until then `steam-ui-patches` keeps polling. Disabled, there is no unit;
the switch restores the key if SteamVR is stopped, otherwise the runtime
drop-in does when it stops.
**Security:** the port listens on `127.0.0.1` only; keep Developer Mode off
(see "DevTools on the LAN" in [UI patches](#ui-patches-uipatchespatches)).
### 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`](#launcher-menu-launchermenu).
### Clipboard sync (`clipboardSync.enable`)
**Problem:** the Steam session's X displays and the nested desktop have
separate clipboards.
**Fix:** [clipboard-sync](https://github.com/dnut/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-widgets` makes fullscreen
fill just the window. **Remove when** gamescope shows fullscreen X11
windows in VR.
- **`disableAv1`**: `media.av1.enabled = false`. The Frame's
decoder driver (`iris`) has no AV1, only H.264, HEVC and VP9, so YouTube
and co. send VP9/H.264, decoded in hardware, instead of software AV1.
**Remove when** a SteamOS kernel adds AV1 to `iris`.
- **`prefs`:** further `about:config` values for every profile; they can
also override the fixes above.
- **`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 (a normal Firefox profile with its
own browser data, created on first use).
`prefs` and the fixes 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.)
Changes take effect at the next start of Firefox.
The desktop profile undoes the fullscreen fix only while its Firefox runs:
the launcher links the profile's `user.js` to
`/app/etc/firefox/steam-frame-nix-desktop-user.js` (a sandbox path) right
before starting Firefox, waits for it, and once it has exited and the
profile is no longer in use removes the link and the value Firefox stored
from it in `prefs.js`. A second launch that just hands a URL to the running
Firefox leaves both in place. A `user.js` of your own is never touched (the
fix then stays on in that profile). After a crash, the next launch or
`steam-frame-nix-cleanup` (on switch) removes them.
Older versions linked or copied a `user.js` into every profile; those and
the values they left in `prefs.js` are removed by `steam-frame-nix-cleanup`
on switch, for each profile not in use at that moment.
### Jellyfin hardware decoding (`jellyfin.hardwareDecoding.*`)
For the Flathub [Jellyfin Desktop](https://github.com/jellyfin/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=dri` covers only the GPU, and Flatpak
has nothing between that and `devices=all`;
- mpv never tries: Jellyfin hard-sets `hwdec=auto-copy`, and mpv's `auto`
probing leaves out V4L2 M2M on purpose (its quality varies by SoC).
Jellyfin has no way to pass mpv options.
**Fix:** device access (`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` (the `hwdec` option); 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; only its store path is
exposed (read-only) to the sandbox. It would work for any libmpv app that
sets `hwdec=auto*`, but only Jellyfin Desktop is set up here.
These are not written to Flatpak's overrides: a desktop entry shadowing the
Flatpak's (`~/.local/share/applications/org.jellyfin.JellyfinDesktop.desktop`,
same ID, so the KDE menu and the "+" menu start it) passes them as
`flatpak run` options, so they apply to launches from that entry and are
gone with it. From a terminal:
```sh
flatpak run --branch=stable --arch=aarch64 --command=jellyfin-desktop \
--device=all --filesystem=<shim>:ro \
--env=LD_PRELOAD=<shim>/lib/mpv-hwdec-shim.so \
--env=SFN_MPV_HWDEC=v4l2m2m-copy,auto-copy org.jellyfin.JellyfinDesktop
```
(the exact line with the store path: `grep ^Exec=
~/.local/share/applications/org.jellyfin.JellyfinDesktop.desktop`, or the
read-only option `steamFrame.jellyfin.hardwareDecoding.command`). Older
versions used a Flatpak override (via nix-flatpak or a Home Manager link);
`steam-frame-nix-cleanup` removes their entries from it.
Changes take effect at the next start of Jellyfin. Log (the command above
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:
```nix
services.flatpak.packages = [ "org.jellyfin.JellyfinDesktop" ];
```
**Caveats:**
- `devices=all` gives 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).
## Changes outside Nix (exceptions)
Everything not listed here is a Home Manager link into the Nix store or
lives in memory (the UI patches). These are written at runtime:
| Path | Feature | Lifetime | Removed by |
|---|---|---|---|
| `VRWebHelper.DebuggerEnabled` in `~/.config/openvr/config/steamvr.vrsettings` | [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable) | only while SteamVR runs | SteamVR stopping (runtime drop-in below); `steam-frame-nix-cleanup` while SteamVR is stopped |
| `~/.local/state/steam-frame-nix/steamvr-debugger.armed` | SteamVR debugger: the key's previous value | while SteamVR runs; after a power loss until the next SteamVR start or cleanup | SteamVR stopping; `steam-frame-nix-cleanup` |
| `/run/user/1000/systemd/user/steamvr.service.d/50-steam-frame-nix-debugger.conf`, `/run/user/1000/steam-frame-nix/steamvr-debugger-restore` | SteamVR debugger: puts the key back when SteamVR stops, without Nix | until reboot (tmpfs) | reboot; `steam-frame-nix-cleanup` while SteamVR is stopped and the debugger is off |
| `~/.local/state/steam-frame-nix/ui-patches/<name>.json` | [persistent state](#ui-patches-uipatchespatches) of dashboard patches: window control bar placements (`frame-controls`), "Steam hidden" (`steam-close-button`). SteamOS's `steamvr.service` deletes `~/.cache/SteamVR` (the dashboard's own browser storage) on every SteamVR start. | until removed: kept when a patch is disabled (the choices come back when you enable it again) | `steam-frame-nix-cleanup --all`, `install.sh uninstall` |
| mtime of `~/.local/share/icons/hicolor` | [icon fallbacks](#icon-fallbacks-launchermenuiconfallbacks): a running Steam rescans icons | only the directory's timestamp | nothing to remove |
**`steam-frame-nix-cleanup`** (`install.sh cleanup`) knows everything any
version of steam-frame-nix wrote outside the store, removes only what is
provably its own (everything else is reported as "left alone") and can be
run again safely; `--dry-run` shows what it would do.
- On every switch, `cleanup --orphans` removes what the configuration no
longer uses (never the saved patch state).
- `steam-frame-nix-cleanup --all` removes everything, also the saved patch
state. SteamVR's key can't be changed while SteamVR runs: it is then left
to the runtime drop-in (restored when SteamVR stops).
- Without Nix or after a rollback it runs from the script
(bash, coreutils, findutils, jq, all in SteamOS' `/usr/bin`):
`curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- cleanup --all`.
- It also removes what older versions left: the icon fallback links and
their list (`~/.local/state/steam-frame-nix/icon-fallbacks`), the
debugger marker `steamvr-debugger`, Firefox `user.js` copies and links
and their `prefs.js` values, the Jellyfin hwdec entries of
`~/.local/share/flatpak/overrides/org.jellyfin.JellyfinDesktop` (an empty
override file too) and the old shim copy in
`~/.var/app/org.jellyfin.JellyfinDesktop`.
### Only while running
- The UI patches (Steam, SteamVR dashboard, VR keyboard) live in the pages'
memory; stopping `steam-ui-patches` / `steam-keyboard-patch` reverts them.
- clipboard-sync runs from KDE autostart (a Home Manager link).
- Firefox: the desktop profile's `user.js` link exists only while its
Firefox runs (see [Firefox](#firefox-firefox)).
- Jellyfin: the hardware decoding permissions are `flatpak run` options of
the desktop entry, not a Flatpak override.
### Set up by install.sh
`install.sh install` (the bootstrap, not the modules) changes more, and
`install.sh uninstall` undoes it:
- Nix via [nix-installer](https://github.com/NixOS/nix-installer): `/nix`
(bind mount of `/home/nix`), files in `/etc` (systemd units, profile
scripts, `nix.conf`) and its receipt `/nix/receipt.json`; the read-only
root is unlocked only while it installs or uninstalls;
- `experimental-features = nix-command flakes` in `~/.config/nix/nix.conf`
if Nix was already there without flakes;
- `~/nix-config` (your configuration, a git repository) and the link
`~/.config/home-manager` to it; uninstall removes the link, never the
configuration;
- dotfiles Home Manager found in its way, renamed to `*.hm-backup-<time>`
(kept by uninstall);
- `~/.local/state/home-manager` and `~/.local/state/nix` (profiles,
generations), `~/.nix-profile`, `~/.nix-defexpr`, `~/.nix-channels`,
`~/.cache/nix`.
### App data you create
Not steam-frame-nix's to remove: the Firefox desktop profile
(`~/.var/app/org.mozilla.firefox/config/mozilla/firefox/desktop`, browser
data), and whatever apps keep in `~/.var/app/*`, Flatpak apps and their
runtimes.
## Rollback
`home-manager generations` lists previous generations; run
`<store path>/activate` of the one you want. Generations with
steam-frame-nix clean up after themselves on activation (orphans). After
rolling back to a generation **without** steam-frame-nix, or to one older
than `steam-frame-nix-cleanup` (2026-09-29), remove what the newer one wrote
outside the store:
```sh
curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- cleanup --all
# or: nix run github:lhns/steam-frame-nix#cleanup -- --all
```
## Uninstall
```sh
curl -fsSL https://steam-frame-nix.lhns.de | bash -s -- uninstall # --keep-nix keeps Nix
```
stops Home Manager's user services (reverting the UI patches), runs
`cleanup --all`, runs `home-manager uninstall`, then removes Nix and the
per-user Nix state (see [Set up by install.sh](#set-up-by-installsh)). If
SteamVR is running, its key is restored when SteamVR stops (the closing
message says so). Your configuration, `*.hm-backup-*` files, app data and
Flatpaks stay.
To drop steam-frame-nix from a Home Manager configuration you keep, first
run `steam-frame-nix-cleanup --all`, then remove it and switch. Or set Home
Manager's `uninstall = true;` in the configuration that still imports
steam-frame-nix and switch: its activation runs `cleanup --all` while Home
Manager removes its files. (`home-manager uninstall` alone doesn't load
steam-frame-nix's modules, so it can't clean up after them.)
## License
[Apache License 2.0](LICENSE).