commit 2dc9ccb83e0c8fe918b84dac26fb7d6a2df2d6bf Author: Pierre Kisters <1524059+lhns@users.noreply.github.com> Date: Sun Sep 27 15:09:13 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcfc4a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result* diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5e630f --- /dev/null +++ b/README.md @@ -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=`). +- **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 (`/activate`). diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..f60c92c --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5ad9233 --- /dev/null +++ b/flake.nix @@ -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; }; + }; + }; +} diff --git a/modules/clipboard-sync.nix b/modules/clipboard-sync.nix new file mode 100644 index 0000000..b16b9bd --- /dev/null +++ b/modules/clipboard-sync.nix @@ -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 + ''; + }; +} diff --git a/modules/firefox.nix b/modules/firefox.nix new file mode 100644 index 0000000..77bb480 --- /dev/null +++ b/modules/firefox.nix @@ -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 @@ + ''; + }; +} diff --git a/modules/hidden-apps.nix b/modules/hidden-apps.nix new file mode 100644 index 0000000..00696b9 --- /dev/null +++ b/modules/hidden-apps.nix @@ -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 + ''; + }); +} diff --git a/modules/keyboard-layout.nix b/modules/keyboard-layout.nix new file mode 100644 index 0000000..e8b00e4 --- /dev/null +++ b/modules/keyboard-layout.nix @@ -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"; + }; +} diff --git a/modules/portal.nix b/modules/portal.nix new file mode 100644 index 0000000..4802690 --- /dev/null +++ b/modules/portal.nix @@ -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} + ''; + }; +} diff --git a/modules/session.nix b/modules/session.nix new file mode 100644 index 0000000..4007904 --- /dev/null +++ b/modules/session.nix @@ -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 /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} + ''); +} diff --git a/modules/vr-keyboard.nix b/modules/vr-keyboard.nix new file mode 100644 index 0000000..c6def33 --- /dev/null +++ b/modules/vr-keyboard.nix @@ -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" ]; + }; +} diff --git a/modules/vr-keyboard/vrkbd-helper.mjs b/modules/vr-keyboard/vrkbd-helper.mjs new file mode 100644 index 0000000..0d8af8f --- /dev/null +++ b/modules/vr-keyboard/vrkbd-helper.mjs @@ -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 [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: the extra keys (Esc, Del, Home, End, arrows), optionally with +// modifiers; or ctrl and/or alt (+ optional shift) with one key +// type: one character Steam's key emulation can't produce: non-ASCII +// (äöü߀§°´…) or | @ { [ ] } \ ~ ^ ` +// down: / up: 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); +} diff --git a/modules/vr-keyboard/vrkbd-patch.js b/modules/vr-keyboard/vrkbd-patch.js new file mode 100644 index 0000000..5b067c8 --- /dev/null +++ b/modules/vr-keyboard/vrkbd-patch.js @@ -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(":"), +// 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'; +})()