Home Manager modules for the Steam Frame

Portal fix, Steam-session keyboard layout, VR keyboard patch, hidden
apps, clipboard-sync and a Firefox Flatpak launcher, all under
steamFrame.*, plus the outer-session user-services mechanism.
This commit is contained in:
Pierre Kisters committed 2026-09-27 15:09:13 +02:00
commit 2dc9ccb83e
13 files changed
+998

No files matched your search

+1
View File
@@ -0,0 +1 @@
result*
+263
View File
@@ -0,0 +1,263 @@
# steam-frame-nix
[Home Manager](https://github.com/nix-community/home-manager) modules for the
Valve Steam Frame: SteamOS on `aarch64-linux`, standalone home-manager on a
non-NixOS system. They work around quirks of the Frame's two graphical
sessions (portal config, keyboard layout, VR keyboard, clipboard, Firefox)
declaratively, so every change can be reverted by activating an older
home-manager generation.
All options live under `steamFrame.*`. Everything except the portal fix is
off by default.
## Two sessions
The Frame runs two graphical sessions at once, and most of the workarounds
below exist because of the difference between them:
| | 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 |
Consequences:
- **Wallet:** there should be exactly one `kwalletd6`, on the outer bus. Apps
started from the desktop would otherwise start a second wallet on the
private bus, and secrets saved there are invisible in VR. Launchers can
prefix their `Exec=` line with `steamFrame.outerBusEnv`
(`env DBUS_SESSION_BUS_ADDRESS=<outer bus>`).
- **User services:** home-manager's own `reloadSystemd` step is skipped when
switching from the desktop terminal (wrong `XDG_RUNTIME_DIR`), so
`steamFrame.userServices` talks to the outer user manager directly.
- **Launchers:** the Steam session's "+" menu only sees
`~/.local/share/applications` (not `~/.nix-profile/share`), so app entries
are written there, shadowing Flatpak/package entries under the same ID.
## Usage
Requirements: Nix with flakes enabled and standalone home-manager.
```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 = "25.11";
targets.genericLinux.enable = true;
steamFrame = {
keyboardLayout = "de";
vrKeyboard.enable = true;
clipboardSync.enable = true;
firefox.enable = true;
# Only relevant with Steam Developer Mode on (see hidden-apps below).
hiddenApps = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
};
# 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.outerBusEnv} flatpak run org.example.App %U
# '';
}
```
Switch from a terminal **in the nested desktop** (clipboard-sync is restarted
with the desktop's environment):
```sh
home-manager switch --flake .#steamos
```
Individual modules are available as
`homeManagerModules.{session,portal,keyboard-layout,vr-keyboard,hidden-apps,clipboard-sync,firefox}`;
`default` imports all of them.
**Steam Developer Mode** (a Steam setting, not managed here) makes the "+"
menu list every desktop entry, including terminals such as Konsole.
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `steamFrame.runtimeDir` | str | `"/run/user/1000"` | `XDG_RUNTIME_DIR` of the outer (Steam/VR) session. |
| `steamFrame.userBus` | str | `"unix:path=${runtimeDir}/bus"` | Outer session D-Bus (user manager, the one `kwalletd6`). |
| `steamFrame.outerBusEnv` | str, read-only | `"env DBUS_SESSION_BUS_ADDRESS=${userBus}"` | Prefix for launchers that must use the outer bus. |
| `steamFrame.userServices.start` | list of str | `[ ]` | User units started on switch if not running (outer user manager). |
| `steamFrame.userServices.restart` | list of str | `[ ]` | User units restarted on every switch (outer user manager). |
| `steamFrame.portalFix.enable` | bool | `true` | Working portal config for the Steam session (OpenURI). |
| `steamFrame.keyboardLayout` | null or str | `null` | XKB layout for the Steam session, e.g. `"de"`. `null` = no drop-in (US). |
| `steamFrame.keyboardVariant` | null or str | `null` | XKB variant for the Steam session. |
| `steamFrame.vrKeyboard.enable` | bool | `false` | Esc/Ctrl/Alt/AltGr and arrow keys on Steam's VR keyboard. |
| `steamFrame.hiddenApps` | list of str | `[ ]` | Desktop entry ids (without `.desktop`) to hide from the "+" and KDE menus. |
| `steamFrame.clipboardSync.enable` | bool | `false` | 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 (`org.mozilla.firefox`) with the fixes below. |
| `steamFrame.firefox.vrFullscreenFix` | bool | `true` | Link a `user.js` with `full-screen-api.ignore-widgets` into existing profiles. |
| `steamFrame.firefox.desktopProfile` | null or str | `"desktop"` | Separate profile used in the nested desktop; `null` disables it. |
## Fixes in detail
### User services (`session.nix`)
**Problem:** `home-manager switch` is usually run from the nested desktop,
whose `XDG_RUNTIME_DIR` and D-Bus can't reach the systemd user manager, so
home-manager skips its `reloadSystemd` step ("User systemd daemon not
running"): new or changed user units are neither reloaded nor started.
**Fix:** the activation entry `steamFrameUserServices` exports the outer
session's `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS` and runs
`/usr/bin/systemctl --user daemon-reload`, then `start` for
`userServices.start` and `restart` for `userServices.restart`. The entry
always runs (also with both lists empty), so unit files you define yourself
are at least reloaded.
### Portal (`portalFix`)
**Problem:** the Steam 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 has the holo and
gamescope backends but no `gamescope-portals.conf`. With
`XDG_DESKTOP_PORTAL_DIR` set, the portal reads config only from there, selects
no backend and offers no OpenURI: no app in the Steam session can open links.
**Fix:** an own portal dir in `~/.local/share` with links to Valve's
`.portal` files plus a config (`default=holo;gamescope`), and a drop-in on
`xdg-desktop-portal.service` pointing at it. Only the systemd-managed (outer)
portal is affected; the desktop's portal keeps `kde-portals.conf`.
**Remove when** SteamOS ships a `gamescope-portals.conf`
(`steamFrame.portalFix.enable = false`).
### Keyboard layout (`keyboardLayout`, `keyboardVariant`)
**Problem:** gamescope and its Xwayland displays use xkbcommon defaults (US)
unless `XKB_DEFAULT_*` is set; KDE's layout setting only affects the nested
desktop. `~/.config/environment.d` isn't read by the user manager on the
Frame.
**Fix:** a drop-in on `gamescope-session.service` setting
`XKB_DEFAULT_LAYOUT` (and `XKB_DEFAULT_VARIANT`). Takes effect the next time
the Steam session starts.
**Remove when** SteamOS applies a layout setting to gamescope.
### VR keyboard (`vrKeyboard.enable`)
**Problem:** Steam's VR keyboard has hardcoded layouts without Ctrl, Alt or
Esc. In VR, Steam can't press real keys
(`SteamClient.Input.ControllerKeyboardSetKeyState` throws "Unknown method"),
and its text emulation (`ControllerKeyboardSendText`) only maps plain ASCII:
non-ASCII characters and anything needing AltGr or a dead key on the German
keymap (`| @ { [ ] } \ ~ ^`, backtick, `ä ö ü €`) come out as `1`.
**Fix:** the `vr-keyboard` user service (`vrkbd-helper.mjs`, Node) injects
`vrkbd-patch.js` into Steam's UI at runtime through Steam's CEF DevTools port
(`127.0.0.1:8080`; SteamOS starts Steam with `-cef-enable-debugging`) and
re-injects it after Steam restarts or the keyboard popup is recreated.
Steam's files are never modified; without the service, a restart of Steam
gives the stock keyboard. The service is restarted on every switch (via
`userServices.restart`) so a changed patch is re-injected.
- Bottom row becomes `Esc Ctrl Alt [Space] AltGr ← ↑ ↓ → Close`.
- Ctrl/Alt chords and Esc are pressed with `xdotool key` on `:0`, where X
focus follows the window selected in VR.
- While Ctrl/Alt is toggled and the keyboard is open, the real modifier is
held down (e.g. Ctrl+scroll to zoom).
- Problem characters are typed with `xdotool type`; everything else still
goes through Steam.
**Layouts:** the extra-character handling (which characters are routed to
xdotool, and the keysym names used for umlauts in chords) targets the German
(`de`) keymap. On other layouts it is harmless: those characters are simply
typed by xdotool instead of Steam, and Esc/Ctrl/Alt/arrows work regardless.
**Security:** requests come from Steam's UI JS, so the helper uses an
allowlist: Ctrl/Alt chords with a single key, the extra keys (Esc, Del, Home,
End, arrows), hold/release of Ctrl/Alt, and single non-ASCII or AltGr
characters. It cannot type plain ASCII text or press Enter on its own.
**Caveat:** the patch depends on Steam UI internals, including internal
webpack module ids (e.g. `40222` for layouts, `5363` for the keyboard
manager). A Steam update can break it; the keys then just don't appear.
Tested with Steam client 1790377368.
**Remove when** Steam's VR keyboard gets these keys itself.
### Hidden apps (`hiddenApps`)
**Problem:** with Steam Developer Mode on, the "+" menu lists every desktop
entry GLib would show, including system tools you never want in VR.
**Fix:** a user entry with `Hidden=true` in `~/.local/share/applications`
masks the system one (also in the KDE menu).
The "+" menu itself always hides executables named `steam` or
`vrurlhandler`, and unless Developer Mode is on also terminals and similar
tools such as `konsole`, `dolphin`, `vlc`, `sh` and `lxterminal` (filter in
Steam's UI JS). Enable Developer Mode to get Konsole in VR.
### 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 with your `pkgs` (its own flake outputs are x86-only). Started via KDE
autostart (phase 2), not systemd: the desktop can't reach the user manager
and `:2` must exist first. Each switch restarts it if the running binary
isn't the current build, so run `home-manager switch` from a desktop
terminal.
### Firefox (`firefox.*`)
Assumes the Flathub Firefox Flatpak (`org.mozilla.firefox`, stable branch).
The launcher's desktop entry shadows the Flatpak's own (same ID), so
default-browser associations for `org.mozilla.firefox.desktop` keep working.
- **`vrFullscreenFix`:** real fullscreen is broken in the Steam session:
gamescope focuses the fullscreen window but never shows it, so Firefox looks
frozen. A `user.js` with `full-screen-api.ignore-widgets` makes fullscreen
fill only the Firefox window, which in VR can be as large as you like. It is
linked into every existing profile on each switch (existing non-symlink
`user.js` files are left alone). **Remove when** gamescope shows fullscreen
X11 windows in VR.
- **`desktopProfile`:** the two sessions can't see each other's running
Firefox, so a second instance stops at the locked profile. In the nested
desktop (`XDG_CURRENT_DESKTOP=KDE`) the launcher uses a separate profile
(without the `user.js`; fullscreen works there).
## Rollback
`home-manager generations` lists previous generations; run the `activate`
script of the one you want (`<store path>/activate`).
Generated
+44
View File
@@ -0,0 +1,44 @@
{
"nodes": {
"clipboard-sync-src": {
"flake": false,
"locked": {
"lastModified": 1759253700,
"narHash": "sha256-pDsDzWEBaZlT9lHsBZMGm8aBJGncMxqerKwkzjEM/EI=",
"owner": "dnut",
"repo": "clipboard-sync",
"rev": "138a59b8f3044dd9e7dcccd9607bbbb48c14bae6",
"type": "github"
},
"original": {
"owner": "dnut",
"repo": "clipboard-sync",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1790463110,
"narHash": "sha256-hKlVl12B1dF0Q5vd9dY3lIJM5mFGWYSlXwSLAqHZ1+s=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e158d9ed9b51c98974c5e66e1ba1c9e0255fecaa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"clipboard-sync-src": "clipboard-sync-src",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+35
View File
@@ -0,0 +1,35 @@
{
description = "Home Manager modules for the Valve Steam Frame (SteamOS, aarch64-linux)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
clipboard-sync-src = {
url = "github:dnut/clipboard-sync";
flake = false; # just the source; its flake outputs are x86-only
};
};
outputs = { self, nixpkgs, clipboard-sync-src }:
let
modules = {
session = ./modules/session.nix;
portal = ./modules/portal.nix;
keyboard-layout = ./modules/keyboard-layout.nix;
vr-keyboard = ./modules/vr-keyboard.nix;
hidden-apps = ./modules/hidden-apps.nix;
# Built with the consumer's pkgs; only the source comes from our input.
# The key lets the module system deduplicate it when it is imported both
# directly and through `default`.
clipboard-sync = {
key = "steam-frame-nix/clipboard-sync";
_file = ./modules/clipboard-sync.nix;
imports = [ (import ./modules/clipboard-sync.nix { inherit clipboard-sync-src; }) ];
};
firefox = ./modules/firefox.nix;
};
in {
homeManagerModules = modules // {
default = { imports = builtins.attrValues modules; };
};
};
}
+57
View File
@@ -0,0 +1,57 @@
# clipboard-sync: bridges the clipboards of the Steam session's X displays and
# the nested Plasma desktop (own Wayland + Xwayland :2).
# Started via KDE autostart, not systemd: the nested desktop has no access to
# the user systemd instance, and clipboard-sync must start after :2 exists.
#
# Built from source with the consumer's pkgs (the upstream flake outputs are
# x86-only); flake.nix passes the source in through this closure.
{ clipboard-sync-src }:
{ config, pkgs, lib, ... }:
let
cfg = config.steamFrame.clipboardSync;
in {
options.steamFrame.clipboardSync = {
enable = lib.mkEnableOption ''
clipboard-sync between the Steam session and the nested desktop
(KDE autostart; restarted on switch when the build changed)
'';
package = lib.mkOption {
type = lib.types.package;
default = pkgs.rustPlatform.buildRustPackage {
pname = "clipboard-sync";
version = "git";
src = clipboard-sync-src;
cargoLock.lockFile = "${clipboard-sync-src}/Cargo.lock";
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.libxcb ];
};
defaultText = lib.literalMD "built from the `clipboard-sync-src` flake input";
description = "The clipboard-sync package.";
};
};
config = lib.mkIf cfg.enable {
home.packages = [ cfg.package ];
xdg.configFile."autostart/clipboard-sync.desktop".text = ''
[Desktop Entry]
Type=Application
Name=clipboard-sync
Exec=${cfg.package}/bin/clipboard-sync
X-KDE-autostart-phase=2
NoDisplay=true
'';
# (Re)start on switch if not running the current build.
# Run `home-manager switch` from a desktop terminal so it gets the desktop env.
home.activation.startClipboardSync = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
want="${cfg.package}/bin/clipboard-sync"
pid="$(${pkgs.procps}/bin/pgrep -x clipboard-sync | head -n1 || true)"
have="$( [ -n "$pid" ] && readlink "/proc/$pid/exe" || true )"
if [ "$have" != "$want" ]; then
${pkgs.procps}/bin/pkill -x clipboard-sync || true
run ${pkgs.util-linux}/bin/setsid -f "$want" >/dev/null 2>&1
fi
'';
};
}
+106
View File
@@ -0,0 +1,106 @@
# Firefox (Flathub Flatpak org.mozilla.firefox) on the Frame.
#
# vrFullscreenFix: real fullscreen is broken in the Steam session: gamescope
# gives the fullscreen X11 window input focus but never shows it, so Firefox
# looks frozen. With ignore-widgets, fullscreen (e.g. YouTube) only fills the
# Firefox window itself, which in VR can be made as large as you like.
# Profile names are random, and flakes can't read $HOME at eval time, so the
# user.js is linked into every existing profile on each switch.
#
# desktopProfile: the Steam session and the nested desktop have separate D-Bus
# buses and displays, so a second Firefox can't find the running one and
# stops at the locked profile. In the nested desktop (XDG_CURRENT_DESKTOP=KDE)
# the launcher uses its own profile instead (the launcher creates the dir,
# Firefox fills it). It gets no user.js: real fullscreen works there.
#
# The launcher's desktop entry shadows the Flatpak's own entry (same ID), so
# MIME/default-browser associations for org.mozilla.firefox.desktop still
# apply, and the Steam "+" menu (which only reads ~/.local/share/applications)
# sees it.
{ config, pkgs, lib, ... }:
let
cfg = config.steamFrame.firefox;
userJs = pkgs.writeText "firefox-user.js" ''
user_pref("full-screen-api.ignore-widgets", true);
'';
ffDir = "$HOME/.var/app/org.mozilla.firefox/config/mozilla/firefox";
profileDir = "${ffDir}/${cfg.desktopProfile}";
skipDesktopProfile = lib.optionalString (cfg.desktopProfile != null)
''[ "$(basename "$prof")" = ${lib.escapeShellArg cfg.desktopProfile} ] && continue'';
firefox = pkgs.writeShellScript "firefox-launcher" (''
profile=()
'' + lib.optionalString (cfg.desktopProfile != null) ''
if [ "$XDG_CURRENT_DESKTOP" = KDE ]; then
# Firefox exits (status 1) if the --profile dir doesn't exist yet.
mkdir -p "${profileDir}"
profile=(--profile "${profileDir}")
fi
'' + ''
exec /usr/bin/flatpak run --branch=stable --arch=aarch64 --command=firefox \
--file-forwarding org.mozilla.firefox "''${profile[@]}" "$@"
'');
in {
options.steamFrame.firefox = {
enable = lib.mkEnableOption ''
the Firefox Flatpak (org.mozilla.firefox) launcher with Steam Frame fixes
'';
vrFullscreenFix = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Link a user.js setting full-screen-api.ignore-widgets into every
existing Firefox profile (except the desktop profile), so fullscreen
fills only the Firefox window instead of freezing in the Steam session.
'';
};
desktopProfile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = "desktop";
description = ''
Name of the separate profile the launcher uses in the nested desktop
(XDG_CURRENT_DESKTOP=KDE), so both sessions can run Firefox at once.
null uses the default profile in both sessions.
'';
};
};
config = lib.mkIf cfg.enable {
home.activation.firefoxUserJs = lib.mkIf cfg.vrFullscreenFix
(lib.hm.dag.entryAfter [ "writeBoundary" ] ''
for prof in "${ffDir}"/*/; do
${skipDesktopProfile}
[ -f "$prof/prefs.js" ] || continue # only real profiles
target="$prof/user.js"
if [ -e "$target" ] && [ ! -L "$target" ]; then
echo "firefox: skipping $target (not managed by us, move it away to adopt)"
continue
fi
run ln -sfn ${userJs} "$target"
done
'');
xdg.dataFile."applications/org.mozilla.firefox.desktop".text = ''
[Desktop Entry]
Type=Application
Name=Firefox
GenericName=Web Browser
Icon=org.mozilla.firefox
Exec=${firefox} @@u %u @@
StartupWMClass=firefox
StartupNotify=true
Terminal=false
Categories=Network;WebBrowser;
MimeType=application/json;application/pdf;application/rdf+xml;application/rss+xml;application/x-xpinstall;application/xhtml+xml;application/xml;audio/flac;audio/ogg;audio/webm;image/avif;image/gif;image/jpeg;image/png;image/svg+xml;image/webp;text/html;text/xml;video/ogg;video/webm;x-scheme-handler/chrome;x-scheme-handler/http;x-scheme-handler/https;x-scheme-handler/mailto;
Actions=new-window;new-private-window;
[Desktop Action new-window]
Name=New Window
Exec=${firefox} --new-window @@u %u @@
[Desktop Action new-private-window]
Name=New Private Window
Exec=${firefox} --private-window @@u %u @@
'';
};
}
+21
View File
@@ -0,0 +1,21 @@
# Hide system apps from menus, including the Steam session's "+" menu (with
# Developer Mode on it lists every desktop entry GLib would show). A user
# entry with Hidden=true in ~/.local/share/applications masks the one in
# /usr/share/applications. Also hides them from the nested desktop's KDE menu.
{ config, lib, ... }: {
options.steamFrame.hiddenApps = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "lxterminal" "cmake-gui" "firewall-config" "renderdoc" ];
description = "Desktop entry ids (without .desktop) to hide from the \"+\" menu and the KDE menu.";
};
config.xdg.dataFile = lib.genAttrs
(map (id: "applications/${id}.desktop") config.steamFrame.hiddenApps)
(_: {
text = ''
[Desktop Entry]
Hidden=true
'';
});
}
+37
View File
@@ -0,0 +1,37 @@
# Keyboard layout for the Steam session. gamescope (and its Xwayland displays
# :0/:1) uses xkbcommon defaults, i.e. US, unless XKB_DEFAULT_* is set. KDE's
# layout setting only applies to the nested desktop. systemd --user on the
# Frame does not pick up ~/.config/environment.d, so set it directly on the
# unit that launches gamescope. Takes effect the next time the Steam session
# starts.
{ config, lib, ... }:
let
cfg = config.steamFrame;
in {
options.steamFrame = {
keyboardLayout = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "de";
description = ''
XKB layout for the Steam session (XKB_DEFAULT_LAYOUT). null leaves
gamescope's default (US) and writes no drop-in.
'';
};
keyboardVariant = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "nodeadkeys";
description = "XKB variant for the Steam session (XKB_DEFAULT_VARIANT).";
};
};
config = lib.mkIf (cfg.keyboardLayout != null || cfg.keyboardVariant != null) {
xdg.configFile."systemd/user/gamescope-session.service.d/keyboard.conf".text =
"[Service]\n"
+ lib.optionalString (cfg.keyboardLayout != null)
"Environment=XKB_DEFAULT_LAYOUT=${cfg.keyboardLayout}\n"
+ lib.optionalString (cfg.keyboardVariant != null)
"Environment=XKB_DEFAULT_VARIANT=${cfg.keyboardVariant}\n";
};
}
+40
View File
@@ -0,0 +1,40 @@
# Workaround for the Steam Frame image (SteamOS 0.3.0, build 20260922):
# the Steam session's xdg-desktop-portal gets XDG_DESKTOP_PORTAL_DIR pointing at
# /usr/share/xdg-desktop-portal/gamescope-portals, which contains the holo and
# gamescope backends but no gamescope-portals.conf (and no UseIn=). With that
# variable set, the portal only reads config from that dir, so it selects no
# backend and offers no OpenURI: no app in the Steam session can open links.
# Fix: our own portal dir = links to Valve's .portal files + a config.
# Remove once SteamOS ships a gamescope-portals.conf.
{ config, lib, ... }:
let
sys = "/usr/share/xdg-desktop-portal/gamescope-portals";
dir = "xdg-desktop-portal/gamescope-portals";
in {
options.steamFrame.portalFix.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Give the Steam session's xdg-desktop-portal a working config
(gamescope-portals.conf) so apps there can open links (OpenURI).
'';
};
config = lib.mkIf config.steamFrame.portalFix.enable {
xdg.dataFile."${dir}/holo.portal".source =
config.lib.file.mkOutOfStoreSymlink "${sys}/holo.portal";
xdg.dataFile."${dir}/gamescope.portal".source =
config.lib.file.mkOutOfStoreSymlink "${sys}/gamescope.portal";
xdg.dataFile."${dir}/gamescope-portals.conf".text = ''
[preferred]
default=holo;gamescope
'';
# Only affects the systemd-managed (outer) portal; the nested desktop's
# portal is D-Bus-activated on its own bus and keeps using kde-portals.conf.
xdg.configFile."systemd/user/xdg-desktop-portal.service.d/gamescope-portals.conf".text = ''
[Service]
Environment=XDG_DESKTOP_PORTAL_DIR=%h/.local/share/${dir}
'';
};
}
+72
View File
@@ -0,0 +1,72 @@
# Shared settings for the two sessions on the Steam Frame, and the
# user-services mechanism.
#
# The Frame runs the Steam/VR session (gamescope, systemd user manager, outer
# D-Bus at <runtimeDir>/bus) and a nested Plasma desktop with its own
# XDG_RUNTIME_DIR and private D-Bus. Modules that talk to the outer session use
# `runtimeDir` / `userBus` explicitly.
#
# User services: `home-manager switch` usually runs from the nested desktop,
# whose XDG_RUNTIME_DIR and D-Bus can't reach the user manager, so
# home-manager's own reloadSystemd step is skipped ("User systemd daemon not
# running"). The `steamFrameUserServices` activation entry does it instead,
# pointed at the outer session: it always runs `systemctl --user
# daemon-reload` (so changed unit files are picked up even when both lists are
# empty), then starts the units in `userServices.start` and restarts those in
# `userServices.restart`.
{ config, lib, ... }:
let
cfg = config.steamFrame;
units = lib.escapeShellArgs;
in {
options.steamFrame = {
runtimeDir = lib.mkOption {
type = lib.types.str;
default = "/run/user/1000";
description = "XDG_RUNTIME_DIR of the outer (Steam/VR) session.";
};
userBus = lib.mkOption {
type = lib.types.str;
default = "unix:path=${cfg.runtimeDir}/bus";
defaultText = lib.literalExpression ''"unix:path=''${config.steamFrame.runtimeDir}/bus"'';
description = ''
Address of the outer session D-Bus. The user systemd manager and the
single running kwalletd6 are only reachable over this bus.
'';
};
outerBusEnv = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "env DBUS_SESSION_BUS_ADDRESS=${cfg.userBus}";
defaultText = lib.literalExpression ''"env DBUS_SESSION_BUS_ADDRESS=''${config.steamFrame.userBus}"'';
description = ''
Command prefix for launchers (desktop entry Exec= lines) that must use
the outer bus, e.g. so apps started from the nested desktop use the
one kwalletd6 instead of starting a second wallet on the private bus.
'';
};
userServices = {
start = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "docker.service" ];
description = "User units started on switch if not already running (outer user manager).";
};
restart = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "vr-keyboard.service" ];
description = "User units restarted on every switch (outer user manager).";
};
};
};
config.home.activation.steamFrameUserServices = lib.hm.dag.entryAfter [ "reloadSystemd" ] (''
export XDG_RUNTIME_DIR=${lib.escapeShellArg cfg.runtimeDir} DBUS_SESSION_BUS_ADDRESS=${lib.escapeShellArg cfg.userBus}
run /usr/bin/systemctl --user daemon-reload
'' + lib.optionalString (cfg.userServices.start != [ ]) ''
run /usr/bin/systemctl --user start ${units cfg.userServices.start}
'' + lib.optionalString (cfg.userServices.restart != [ ]) ''
run /usr/bin/systemctl --user restart ${units cfg.userServices.restart}
'');
}
+48
View File
@@ -0,0 +1,48 @@
# Esc/Ctrl/Alt and four arrow keys on Steam's VR keyboard.
# The keyboard is part of Steam's UI (hardcoded layouts in steamui JS), and in
# VR it can only send text; SteamClient.Input.ControllerKeyboardSetKeyState
# throws "Unknown method". So:
# - vrkbd-patch.js is injected at runtime into Steam's UI through its CEF
# DevTools port (127.0.0.1:8080; SteamOS starts Steam with
# -cef-enable-debugging). Steam's files are untouched, so updates don't undo
# it. It rebuilds the bottom row and, while Ctrl/Alt is active (or for Esc),
# hands the key combo to the helper instead of typing text. It also holds
# the real Ctrl/Alt down while the toggle is on (Ctrl+scroll), and routes
# characters Steam's key emulation turns into "1" (non-ASCII, AltGr/dead
# keys on the de keymap: |@{[]}\~^`äöü€…) to the helper.
# - vrkbd-helper.mjs keeps that injection alive (Steam restarts, keyboard popup
# recreated) and presses the combos with `xdotool key` on :0, where gamescope
# keeps X focus on the window selected in VR. It only accepts ctrl/alt
# chords and the extra keys, so the Steam UI can't use it to type text or
# press Enter.
# Depends on Steam UI internals; tested with Steam client 1790377368.
{ config, pkgs, lib, ... }: {
imports = [ ./session.nix ];
options.steamFrame.vrKeyboard.enable = lib.mkEnableOption ''
Esc/Ctrl/Alt/AltGr and arrow keys on Steam's VR keyboard (runtime patch of
Steam's UI through its CEF DevTools port, plus an xdotool helper service)
'';
config = lib.mkIf config.steamFrame.vrKeyboard.enable {
systemd.user.services.vr-keyboard = {
Unit.Description = "Modifier keys for Steam's VR keyboard (CEF patch + xdotool)";
Service = {
ExecStart = lib.escapeShellArgs [
"${pkgs.nodejs}/bin/node"
"${./vr-keyboard/vrkbd-helper.mjs}"
"${./vr-keyboard/vrkbd-patch.js}"
"${pkgs.xdotool}/bin/xdotool"
];
Environment = "VRKBD_DISPLAY=:0";
Restart = "always";
RestartSec = 5;
};
Install.WantedBy = [ "default.target" ];
};
# Restart on every switch so a changed patch is re-injected (it replaces
# the older version).
steamFrame.userServices.restart = [ "vr-keyboard.service" ];
};
}
+102
View File
@@ -0,0 +1,102 @@
// vrkbd-helper: injects vrkbd-patch.js into Steam's UI via CEF DevTools and
// performs the key requests of the patched VR keyboard with xdotool on :0.
// usage: node vrkbd-helper.mjs <patch.js> [xdotool]
import { readFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
const [, , patchPath, xdotool = 'xdotool'] = process.argv;
const PATCH = readFileSync(patchPath, 'utf8');
const CDP = 'http://127.0.0.1:8080/json/list';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ENV = { ...process.env, DISPLAY: process.env.VRKBD_DISPLAY || ':0', LC_ALL: 'C.UTF-8' };
// Allowlist: requests come from Steam's UI JS, so the helper must not be able
// to type ASCII text or press Enter on its behalf. Accepted:
// key:<combo> the extra keys (Esc, Del, Home, End, arrows), optionally with
// modifiers; or ctrl and/or alt (+ optional shift) with one key
// type:<char> one character Steam's key emulation can't produce: non-ASCII
// (äöü߀§°´…) or | @ { [ ] } \ ~ ^ `
// down:<mod> / up:<mod> hold/release ctrl or alt
const SPECIAL = new Set(['Escape', 'Delete', 'Home', 'End', 'Left', 'Right', 'Up', 'Down']);
const KEY = /^([a-z0-9]|space|BackSpace|Tab|period|comma|minus|plus|numbersign|less|slash|ssharp|udiaeresis|odiaeresis|adiaeresis)$/;
const MODKEY = { ctrl: 'Control_L', alt: 'Alt_L' };
function allowedCombo(combo) {
const parts = combo.split('+');
const key = parts.pop();
const mods = new Set(parts);
if (parts.length !== mods.size || ![...mods].every((m) => ['ctrl', 'alt', 'shift'].includes(m))) return false;
if (SPECIAL.has(key)) return true;
return KEY.test(key) && (mods.has('ctrl') || mods.has('alt'));
}
function allowedChar(c) {
if ([...c].length !== 1) return false;
const cp = c.codePointAt(0);
return (cp > 0xa0 && !/\s|\p{C}/u.test(c)) || '|@{[]}\\~^`'.includes(c);
}
const held = new Set(); // modifiers currently held down via keydown
const xdo = (args) => execFile(xdotool, args, { env: ENV }, (err) => err && console.error('xdotool', args.join(' '), err.message));
function releaseAll() { for (const m of held) xdo(['keyup', MODKEY[m]]); held.clear(); }
function handle(msg) {
if (typeof msg !== 'string') return;
const i = msg.indexOf(':');
const op = msg.slice(0, i), arg = msg.slice(i + 1);
if (op === 'key' && allowedCombo(arg)) {
// Modifiers already held down stay pressed; don't press/release them again.
const parts = arg.split('+');
const key = parts.pop();
return xdo(['key', '--', [...parts.filter((m) => !held.has(m)), key].join('+')]);
}
if (op === 'type' && allowedChar(arg)) return xdo(['type', '--', arg]);
if ((op === 'down' || op === 'up') && MODKEY[arg]) {
if (op === 'down') held.add(arg); else held.delete(arg);
return xdo([op === 'down' ? 'keydown' : 'keyup', MODKEY[arg]]);
}
console.error('rejected', msg);
}
async function session() {
const list = await (await fetch(CDP)).json();
const t = list.find((x) => x.title === 'SharedJSContext');
if (!t) throw new Error('SharedJSContext not found');
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0;
const pending = new Map();
const call = (method, params = {}) => new Promise((res) => {
const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params }));
});
const inject = async () => {
const r = await call('Runtime.evaluate', { expression: PATCH, returnByValue: true });
const v = r.result?.result?.value ?? r.result?.exceptionDetails?.exception?.description;
if (v !== 'patched' || process.env.VRKBD_VERBOSE) console.log('inject:', v);
};
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
else if (m.method === 'Runtime.bindingCalled' && m.params.name === '__vrkbdKey') handle(m.params.payload);
else if (m.method === 'Runtime.executionContextCreated') setTimeout(inject, 3000);
};
const closed = new Promise((res) => { ws.onclose = res; });
await call('Runtime.enable');
await call('Runtime.addBinding', { name: '__vrkbdKey' });
// Nothing is held by this helper instance; make the page re-send holds.
await call('Runtime.evaluate', { expression: 'window.__vrkbdHeld = { ctrl: false, alt: false }' });
console.log('connected');
await inject();
// Re-apply periodically: keyboard popup recreated, layouts switched, UI reloaded.
const timer = setInterval(() => inject().catch(() => {}), 15000);
await closed;
clearInterval(timer);
releaseAll();
}
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { releaseAll(); setTimeout(() => process.exit(0), 200); });
for (;;) {
try { await session(); console.log('disconnected'); }
catch (e) { console.error('retry:', e.message); }
releaseAll();
await sleep(5000);
}
+172
View File
@@ -0,0 +1,172 @@
// Injected into Steam's SharedJSContext (CEF, 127.0.0.1:8080) by vrkbd-helper.
// Extends Steam's VR keyboard for gamescope app windows:
// - bottom row: Esc Ctrl Alt [space] AltGr and four separate arrow keys
// - Ctrl/Alt chords and Esc are pressed for real (Steam can't: in VR
// SteamClient.Input.ControllerKeyboardSetKeyState throws "Unknown method")
// - while Ctrl/Alt is active and the keyboard is open, the real modifier is
// held down, so e.g. Ctrl+scroll works
// - characters Steam's own key emulation (ControllerKeyboardSendText) turns
// into "1" -- anything non-ASCII or needing AltGr / a dead key on the X
// keymap -- are typed by the helper instead
// Everything goes through the CDP binding window.__vrkbdKey("<op>:<arg>"),
// executed by vrkbd-helper with xdotool on :0.
// Idempotent: safe to evaluate repeatedly.
(() => {
const VERSION = 5;
const send = (msg) => window.__vrkbdKey && window.__vrkbdKey(msg);
const wr = window.__vrkbdWr ||
(webpackChunksteamui.push([[Symbol('vrkbd')], {}, (r) => { window.__vrkbdWr = r; }]), window.__vrkbdWr);
const Layouts = wr(40222); // keyboard layouts (exports G$: enabled, r_: current)
const VKM = wr(5363).PE; // VirtualKeyboardManager class
const Status = wr(58508).qL; // .VRKeyboardStatus
// ---- bottom row ------------------------------------------------------------
// The keyboard window has a fixed height, so no extra row: Esc/Ctrl/Alt go
// left of the space bar, and Steam's arrow keys become four separate keys
// (stock layouts pair them as [Left, Up-when-shifted], [Right, Down-when-shifted]).
// Key type "Half" for all added keys; the stylesheet below sets their width.
const HALF = 2;
const k = (key, label) => ({ key, label, type: HALF });
const MODS = [k('VKX_Escape', 'Esc'), k('Control', 'Ctrl'), k('Alt', 'Alt')];
const ARROWS = [Layouts.Md, Layouts.GO, Layouts.xl, Layouts.B6] // Steam's ArrowLeft/Up/Down/Right
.map((a) => ({ ...a, type: HALF }));
const isArrow = (x) => (Array.isArray(x) ? x : [x]).some((y) => y && typeof y.key === 'string' && y.key.startsWith('Arrow'));
const bottomRow = (row) => {
const out = [];
for (const x of row) {
if (x === undefined || isArrow(x)) continue;
if (x && x.key === ' ') out.push(...MODS);
out.push(x);
}
out.splice(out.length - 1, 0, ...ARROWS); // before Close/Done (last entry)
return out;
};
let changed = false;
const patchLayout = (l) => {
if (!l || typeof l.rgLayout !== 'function' || l.rgLayout.__vrkbd === VERSION) return;
changed = true;
const orig = l.rgLayout.__vrkbdOrig || l.rgLayout;
const f = (opts) => { const rows = orig(opts); return [...rows.slice(0, -1), bottomRow(rows[rows.length - 1])]; };
f.__vrkbd = VERSION; f.__vrkbdOrig = orig;
l.rgLayout = f;
};
for (const l of Layouts.G$() || []) patchLayout(l);
patchLayout(Layouts.r_());
// ---- text: characters Steam's key emulation can't produce --------------------
// All keyboard text for gamescope windows ends up in
// SteamClient.Input.ControllerKeyboardSendText (via several paths, incl. the
// VR text override), which only maps plain ASCII on the base/shift levels;
// everything else comes out as "1". Hook that one call and hand those
// characters to the helper, in order; the rest passes through unchanged.
if (VKM.prototype.DispatchKeypress.__vrkbdOrig) { // undo the v4 hook
VKM.prototype.DispatchKeypress = VKM.prototype.DispatchKeypress.__vrkbdOrig;
}
const viaHelper = (c) => c.codePointAt(0) > 127 || '|@{[]}\\~^`'.includes(c);
const Input = SteamClient.Input;
if (Input.ControllerKeyboardSendText.__vrkbd !== VERSION) {
const orig = Input.ControllerKeyboardSendText.__vrkbdOrig || Input.ControllerKeyboardSendText;
const patched = function (text, ...rest) {
if (typeof text !== 'string') return orig.call(this, text, ...rest);
let run = '';
for (const c of text) {
if (viaHelper(c)) {
if (run) { orig.call(this, run, ...rest); run = ''; }
send('type:' + c);
} else run += c;
}
if (run) return orig.call(this, run, ...rest);
};
patched.__vrkbd = VERSION; patched.__vrkbdOrig = orig;
Input.ControllerKeyboardSendText = patched;
}
// ---- key handling ----------------------------------------------------------
const kbPopup = [...(g_PopupManager.GetPopups?.() || [])]
.find((p) => p.window?.document.querySelector('[data-key]'));
if (!kbPopup) return 'no keyboard popup yet';
// Widen the added half keys and slim AltGr; the space bar is the only
// flexible key, so it adjusts. Re-applied on every inject (the popup can be recreated).
const doc = kbPopup.window.document;
let style = doc.getElementById('vrkbd-style');
if (!style) { style = doc.createElement('style'); style.id = 'vrkbd-style'; doc.head.appendChild(style); }
style.textContent = ['VKX_Escape', 'Control', 'Alt', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'ArrowRight']
.map((key) => `[data-key="${key}"]`).join(',') + '{ width: 43px !important; }'
+ '[data-key="AltGr"]{ width: 52px !important; }';
const el = doc.querySelector('[data-key]');
let f = el[Object.keys(el).find((x) => x.startsWith('__reactFiber'))];
while (f && !(f.stateNode && f.stateNode.TypeKeyInternal)) f = f.return;
const inst = f?.stateNode;
if (!inst) return 'no keyboard component';
window.__vrkbdInst = inst;
let proto = Object.getPrototypeOf(inst);
while (proto && !Object.prototype.hasOwnProperty.call(proto, 'TypeKeyInternal')) proto = Object.getPrototypeOf(proto);
const active = (v) => (v & 7) !== 0; // toggle state bits: 1 one-shot, 2 locked
const release = (v) => { const z = v & 3; return (z === 1 ? 0 : z) | (v & 4); };
const SYMS = {
' ': 'space', Backspace: 'BackSpace', Enter: 'Return', Tab: 'Tab',
'.': 'period', ',': 'comma', '-': 'minus', '+': 'plus', '#': 'numbersign', '<': 'less',
'/': 'slash', 'ß': 'ssharp', 'ü': 'udiaeresis', 'ö': 'odiaeresis', 'ä': 'adiaeresis',
ArrowLeft: 'Left', ArrowRight: 'Right', ArrowUp: 'Up', ArrowDown: 'Down', // only with Ctrl/Alt; plain arrows stay Steam's
};
const keysym = (key) => {
if (key.startsWith('VKX_')) return key.slice(4);
if (SYMS[key]) return SYMS[key];
if (/^[a-zA-Z0-9]$/.test(key)) return key.toLowerCase();
return null;
};
if (proto.TypeKeyInternal.__vrkbd !== VERSION) {
const orig = proto.TypeKeyInternal.__vrkbdOrig || proto.TypeKeyInternal;
const patched = function (st) {
const key = st?.strKey;
const ts = this.state?.toggleStates || {};
const ctrl = active(ts.Control), alt = active(ts.Alt), shift = active(ts.Shift);
const special = key?.startsWith('VKX_');
const isToggle = key && ['Shift', 'CapsLock', 'Control', 'Alt', 'AltGr'].includes(key);
if (key && !isToggle && (special || ctrl || alt)) {
const sym = keysym(key);
if (sym) {
send('key:' + [ctrl && 'ctrl', alt && 'alt', shift && 'shift', sym].filter(Boolean).join('+'));
this.setState((s) => ({ ...s, toggleStates: { ...s.toggleStates,
Shift: release(s.toggleStates.Shift), Control: release(s.toggleStates.Control),
Alt: release(s.toggleStates.Alt), AltGr: release(s.toggleStates.AltGr) } }));
return;
}
}
return orig.call(this, st);
};
patched.__vrkbd = VERSION; patched.__vrkbdOrig = orig;
proto.TypeKeyInternal = patched;
changed = true;
}
// ---- held modifiers ---------------------------------------------------------
// Keep the real Ctrl/Alt pressed while the toggle is active and the keyboard is
// open (for Ctrl+scroll etc.). One timer per Steam UI instance.
if (!window.__vrkbdHoldTimer) {
window.__vrkbdHeld = { ctrl: false, alt: false };
window.__vrkbdHoldTimer = setInterval(() => {
const i = window.__vrkbdInst;
const ts = i?.state?.toggleStates || {};
const open = !!Status.VRKeyboardStatus?.bIsOpen;
for (const [mod, state] of [['ctrl', ts.Control], ['alt', ts.Alt]]) {
const want = open && active(state);
if (want !== window.__vrkbdHeld[mod]) {
window.__vrkbdHeld[mod] = want;
send((want ? 'down:' : 'up:') + mod);
}
}
}, 250);
}
// Re-render with the new row (only when something changed or it's a new instance).
if (changed || inst.__vrkbd !== VERSION) {
inst.__vrkbd = VERSION;
inst.setState({ standardLayout: Layouts.r_() });
inst.forceUpdate();
}
window.__vrkbdPatched = VERSION;
return 'patched';
})()