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

+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';
})()