mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 03:00:13 +02:00
- 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.
1074 lines
57 KiB
Markdown
1074 lines
57 KiB
Markdown
# 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).
|