mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-04 22:00:08 +02:00
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:
commit
2dc9ccb83e
13 files changed
+998
No files matched your search
@@ -0,0 +1 @@
|
||||
result*
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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; };
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -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 @@
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
'';
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
'');
|
||||
}
|
||||
@@ -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" ];
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
})()
|
||||
Reference in new issue
Block a user