Compare commits

..
Author SHA1 Message Date
DeeJanuzandClaude Opus 5.5 5bc76fc562 hands: a hand fitted as the other side can't bend its fingers backward
A left hand's skeleton could flip to a right hand's mirror image and stay
that way: with the knuckles allowed to bend 35 degrees backward, the mirror
follows half-curled fingers well enough, so curling them showed the fingers
bending back. Live on 2026-10-19 the left hand was fitted as a right one in
41% of frames, knuckles at -18 degrees.

A fit as the other side than the model's call now goes back to the model's
side as soon as its four knuckles bend backward on average (below -10
degrees), and a mirror that needs that never wins the side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:52:27 -06:00
DeeJanuzandClaude Opus 5.5 e01defd36d pointer: three-finger scrolls start and keep going more reliably
User, 2026-10-19: the three-finger tap for scrolling was hard to
trigger, though the drawn hand showed it. Two causes in the log:

- The thumb meets the index a tracker frame or two (55 ms each) before
  the middle, and a ray pinch's press went out at once, so the middle
  joined too late: 4 of 10 tries clicked and dragged the screen. Now,
  with the middle tip already within 5 cm of the thumb, the press waits
  up to POINTER_RAY_THREE_WAIT (110 ms) for it. The aim is locked, so the
  click lands where it pointed; a pinch with the middle away clicks at
  once.
- ft-hands ends its index pinch as the thumb rolls onto the middle tip,
  and calls that a middle pinch only with the thumb 1 cm nearer the
  middle, so scrolls ended after 0.1 s. A three-finger pinch (ray or
  gaze) now goes on while the thumb is still on either finger, and ends
  once it has left both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:36:44 -06:00
DeeJanuz c8ac45e73b Merge remote-tracking branch 'origin/experimental' into experimental 2026-10-19 21:34:38 -06:00
DeeJanuzandClaude Opus 5.5 ecb04a8b1d hands: v19b in the model catalog, latest and recommended
Matches the models.pin of df75f56. v16b moves to older.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:34:32 -06:00
DeeJanuzandClaude Opus 5.5 df75f56679 hands: pin models v19b (contact taught from the bounty prompts)
Hugging Face DeeJanuz/frametop-hands-models 52d3a65, v19b/. Held thumb-index
pinches from the bounty rounds read closed 58% of the time (v16b 21%); replays
track the same hands as v16b. Headset-tested by the maintainer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:33:28 -06:00
DeeJanuzandClaude Opus 5.5 aaa081a4a9 Merge full-keyboard: TaiKeid's Full Keyboard as Frametop's keyboard and Steam's, and hand fixes
Full Keyboard for Steam Frame (TaiKeid, MIT) vendored under keyboard/,
typing on the desktop through the input relay and opening for text
fields; keyboard/FRAMETOP.md has the credits and changes. With it: the
far stretch claims a hand only once it closes, only the laser hand's
claim releases the pointer, Full Keyboard opens where Frametop's keyboard
did, and a low hand keeps its laser (POINTER_RAY_RAISED, off by default).
Headset-tested 2026-10-19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:29:16 -06:00
DeeJanuzandClaude Opus 5.5 7dfcd9b6de pointer: a low hand keeps its laser; typing still holds it back
The hand ray hid once the knuckles were more than 40 cm under the eyes,
so aiming at a keyboard below lost the laser after a few keys. The
user: "hands are still recognized even if they are low, just not if
they are typing". The height test is now a setting, POINTER_RAY_RAISED
(cm under the eyes; 0, the default, is off), with a toggle in the input
settings. Hands typing on a keyboard stay held back by the typing guard,
and a hand in the lap palm up still fails the palm test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:23:58 -06:00
DeeJanuzandClaude Opus 5.5 e5b6e23aa1 pointer, screens: a hand's laser isn't released for the other hand
Typing on Full Keyboard with the right hand's laser, the resting left
hand was claimed by ft-screens (the far stretch: a curled hand counted
as its fist), and any claim released the pointer: 49 releases in a row,
and a pinch lost its click. Now, with a hand's ray driving the pointer,
only that hand's claim releases it, and the far stretch claims a hand
before it starts only while grab that sees it closing. ft-screens'
claim log lines are flushed so they line up with the timed ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:20:14 -06:00
DeeJanuzandClaude Opus 5.5 71538ea5be keyboard: open where Frametop's keyboard did
In Frametop's mode Full Keyboard opened 0.85 m ahead and 0.65 m under
the eyes. A hand's laser aimed that low brings the hand more than 40 cm
under the eyes, where the pointer helper puts the laser away, so typing
by hand laser stopped after a few keys. It now opens 0.70 m ahead and
0.35 m under the eyes, facing them, like screens/keyboard.cpp. Under the
dashboard it sits as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:20:14 -06:00
DeeJanuzandClaude Opus 5.5 2295b03eb7 screens: the far stretch claims a hand only once it closes
Both hands held out a little way with the fingers open (pinch typing on
Full Keyboard by gaze) armed "grab that" on both sides, and they stayed
armed through each pinch since the other three fingers stay open. The
far stretch's pair rule then claimed both hands on every frame: each
pinch went to ft-screens, and the pointer helper's gaze never took the
pointer back. Now a hand is the far stretch's once it closes into a
fist (grab that's Closing, or the far stretch's fist), as before once
the stretch is under way.

Claims not by touch (palm menu, far stretch, grab that) are logged as
they begin and end, since they hold the gaze off the pointer too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:24:13 -06:00
DeeJanuzandClaude Opus 5.5 4838f14036 keyboard: build, service, install, and credits
keyboard/build.sh builds Full Keyboard in the dev container (FULL_KEYBOARD_TESTS=1 with
its tests), loading SteamVR's libopenvr_api first like Frametop's other programs.
frametop-keyboard.service runs it with --frametop alongside SteamVR; keyboard/run.sh
installs it, and install.sh does that in its desktop step. The image builds it and keeps
only what it runs from. uninstall.sh covers its process and settings.

The README credits TaiKeid's Full Keyboard (MIT) and lists keyboard/; keyboard/FRAMETOP.md
records the import, every Frametop change, how it works with Frametop, and how to update
from upstream. docs/reference.md and docs/design.md describe it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:35:25 -06:00
DeeJanuzandClaude Opus 5.5 a48057a7ce input, screens: Full Keyboard types on the desktop and opens for text fields
The relay takes Full Keyboard's hello ("kbhello OPEN", this user only), tells it where
typing goes and whether the screens show ("kb DESKTOP SURFACE"), and types its keys and
composed text on the desktop in order with dictation's ("kbkey", "kbtext", only from the
address that said hello; its text isn't logged). Its held keys come up where they went
down, and when it stops.

ft-screens hears it runs ("fullkb OPEN") and then passes the "vrkeyboard" commands
(text fields, keyboard_toggle, spotwatch, ft-layout) to it through the relay instead of
opening its own panel. A click on Full Keyboard's panel no longer takes typing to Steam.
ft-screens now also tells the relay whether the screens show.

Full Keyboard says in its hello whether it's up for Frametop, at once on a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:30:44 -06:00
DeeJanuzandClaude Opus 5.5 c32b72aff4 keyboard: Frametop mode (--frametop)
Full Keyboard runs as Frametop's keyboard: resident and hidden until
summoned, its settings in ~/.config/frametop-keyboard, Steam keyboard
replacement on at first start, no self-update or SteamOS startup unit of
its own (Frametop ships and starts it).

FrametopLink talks to the input relay on @frametop_relay: hello every
second; the relay says where typing goes, whether the desktop shows,
and when to show, hide or toggle the panel. Only datagrams from that
name and this user count, and its word goes stale after 3 s.
FrametopSink sends desktop typing there (kbkey, kbtext) and the rest to
gamescope as before; a held key is released where it was pressed.

The panel shows over Frametop's desktop with the dashboard closed, with
lasers, while Frametop asked for it or a Steam field has it. Anthy is
also found under Fedora's name (libanthy-unicode.so.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:21:02 -06:00
DeeJanuzandClaude Opus 5.5 a8970962a8 keyboard: import TaiKeid's Full Keyboard for Steam Frame 0.8.1
Upstream: https://github.com/TaiKeid/steam-frame-full-keyboard
Commit: ef915ef75169ac886b7de86d18df2e59150cde2f (Prepare 0.8.1 release)
License: MIT, Copyright (c) 2026 TaiKeid and contributors.
Imported unchanged as a squashed git subtree. Vendored parts keep their
own licenses (keyboard/third_party/README.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:03:45 -06:00
TaiKeid 0dddefa25f Squashed 'keyboard/' content from commit ef915ef
git-subtree-dir: keyboard
git-subtree-split: ef915ef75169ac886b7de86d18df2e59150cde2f
2026-10-19 18:03:45 -06:00
DeeJanuz f98159c8f0 Merge voice-intl: layout-aware dictation, single words, languages and whisper models, tones, hold to talk 2026-10-19 16:00:06 -06:00
DeeJanuz f1e0c79d0c Merge hotplug-sync: hands keep tracking after a colour module hot plug 2026-10-19 16:00:06 -06:00
DeeJanuz 455bcc0dcd Merge combo-input: Keyboard + 3D pointer role, pause cooldown and new-desktop pause 2026-10-19 16:00:06 -06:00
DeeJanuzandClaude Opus 5.5 8f8b2f9b72 Voice: languages and a whisper model choice, single words, tones that aren't cut
From a tester's notes:
- Other languages: a language setting (English, detect, or 18 others) and a
  whisper model (small.en, small, large-v3-turbo). Moonshine types English
  live; other languages go to whisper-server, which voice/whisper-run starts
  with the chosen model. A missing model is downloaded from the Voice page
  (voice/models.sh model NAME, pinned commit, sha256). Text keeps its accents
  and scripts (to_text) and is chunked by UTF-8 bytes.
- Single words: min_seconds 2.0 -> 0.3; a final window the live labels missed
  is decoded anyway; when Moonshine hears nothing in a short window it's
  decoded again cut to its speech, then after 0.2 s of silence; a lone word it
  loops on is typed once. Replays of yes/way/hello/thank you: all type now.
- Tones: the start tone waits for the mic's first audio (or 0.6 s), tones play
  one at a time, the mic stays open until the last ends (speaker and mic share
  the echo-cancel filter), and each tone has silence before and after it.
- The Voice page: "Keys do" (tap to start and stop, or hold to talk),
  language and model; "cancel <source> quiet" for a hold that was a shortcut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:57:53 -06:00
DeeJanuzandClaude Opus 5.5 9949f10061 Relay: dictation types on the desktop's keyboard layout; Right Ctrl can be held to talk
A German tester's "way" came out as "waz": dictation typed US key codes into a
QWERTZ desktop and stripped everything outside ASCII. The relay now reads the
desktop's layout (kxkbrc) and works out each character's keys with
libxkbcommon, Shift and AltGr included, so umlauts and @ type too. Steam and
games still get US. A character the layout can't type is dropped and logged.

A lone modifier bound to dictate_hold starts dictation on its press and stops
on its release. Its press is kept from the desktop, so live text types while
it's held; another key or modifier makes it a shortcut after all: the desktop
gets the press first and the recording is cancelled quietly.

Also: reconcile_desktop_keys no longer releases a key dictation is typing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:57:53 -06:00
DeeJanuzandClaude Opus 5.5 dab0f8b4ca Merge gtk-buttons: browsers' own title bars show minimize and maximize in the desktop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:39:09 -06:00
DeeJanuzandClaude Opus 5.5 715e981b90 Desktop: browsers' own title bars show minimize and maximize again
Firefox and Chromium draw their own title bar and take its buttons from
GTK's decoration layout. In the desktop they're Wayland clients and read
it from the Settings portal, where SteamOS has nothing to fill it in
(no kde-gtk-config, no xdg-desktop-portal-gtk), so they fell back to
GNOME's 'appmenu:close' and showed Close alone. From the dashboard they
run on X11 under gamescope and got GTK's own min/max/close default.

session/ft-gtksettings is a Settings portal backend that serves
org.gnome.desktop.wm.preferences button-layout from kwinrc's button
order (as kde-gtk-config maps it), follows changes, and writes the key
to the desktop's own GSettings for non-Flatpak GTK apps. The session
gives xdg-desktop-portal its own backend folder with it and a copy of
KDE's portals.conf asking kde first, then frametop, for Settings, and
puts the helper's D-Bus service file on XDG_DATA_DIRS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:39:09 -06:00
DeeJanuz e9b81dd2c8 Merge pause-one-controller into combo-input: one-controller pause gesture for the live relay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	docs/reference.md
#	input/game_pause.py
2026-10-19 11:41:07 -06:00
DeeJanuzandClaude Opus 5.5 9e16c47c62 Merge tour: Frametop Tour, onboarding at the desktop's first start and on demand
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:39:58 -06:00
DeeJanuzandClaude Opus 5.5 1548e9656d Merge dashboard-apps: apps from the SteamVR dashboard open as Frametop floating windows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:39:58 -06:00
DeeJanuzandClaude Opus 5.5 57076a2ddc Merge pause-one-controller: with one controller, its thumbstick clicked twice pauses and resumes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:39:58 -06:00
DeeJanuzandClaude Opus 5.5 7bc07ca4d7 Frametop Tour: onboarding at the desktop's first start, and on demand
tour/ft-tour walks through Frametop a page at a time: screens and the 3D mouse,
the key combinations the relay has now and macros, gaze mode with Meta+J/K,
hand tracking with and without gaze, spotwatch, floating windows (with the
dashboard apps choice), pinning, profiles, pausing for VR games, and more.
Pages follow what's installed, with buttons that try things or open their
settings, and practice targets on the gaze and hands pages.

The session's autostart runs ft-tour --first-run, which opens it only while
~/.local/state/frametop/tour.json is missing (or from an older TOUR_VERSION).
Closing, skipping, or finishing writes it; --reset, or the last page's box,
shows it again. Menu entry via tour/install.sh (install.sh step 7, removed by
uninstall.sh), and a Frametop Tour entry in Input Settings' drawer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:39:54 -06:00
DeeJanuzandClaude Opus 5.5 081cf51c30 Floating windows: open apps from the SteamVR dashboard as Frametop windows
New setting DASHBOARD_APPS (Display Settings, Visibility & pins): with it
set to frametop, an app picked in the dashboard's Launch a program list
opens floating in the Frametop desktop instead of as a SteamVR window.

float/ft-dashroute runs in the desktop session and wraps Steam's
SteamClient.Apps.LaunchNonSteamApp over its DevTools port
(float/dash-route.js). ft-floatd's new dashlaunch command finds the app's
desktop file by its command and launches it floating, or says it stays
SteamVR's: setting off, Frametop paused, every floating window in use, or
Steam, SteamVR, a desktop or a layout entry. Then Steam's own launch runs.
The wrapper only diverts while the router's heartbeat is fresh, so with the
desktop closed every launch is Steam's own. update-check checks the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:30:04 -06:00
DeeJanuzandClaude Opus 5.5 2acb7d6be1 Pause gesture: with one controller, its thumbstick clicked twice
The default gesture needs both thumbsticks, so with only one controller
connected Frametop couldn't be paused or resumed from the controller. A
gesture with a button on each controller now falls back to the connected
controller's button alone, clicked twice, until the other one is back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:36 -06:00
DeeJanuzandClaude Opus 5.5 f68d1458be Merge pin-combo-fix: Display Settings pins the chosen screen and keeps its rows in place
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:22:12 -06:00
DeeJanuzandClaude Opus 5.5 fa0ea15372 Merge experimental 7e222e5 (exp.17) into combo-input: cal-inputs Enter key and gaze-owns relay part for the live relay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:10:56 -06:00
DeeJanuzandClaude Opus 5.5 15449f18cc Hands: keep tracking when a colour module hot plug leaves the uppers out of step
After plugging the colour module in on 2026-10-17, XRService logged "Set upper
camera driver not in-sync" and never re-synced: the upper pair ran at 40 fps
against the sides' 30. ft-hands waited for all four cameras to meet within
3 ms, so it got 6-7 sets a second instead of 27-30.

- ft-hands: sets are driven by the side pair; the uppers join when they have a
  frame from the same moment (waiting up to 8 ms). When they meet fewer than
  half of recent sets, their views are dropped and the side pair tracks alone;
  at 95% they come back. Recordings keep only full sets.
- ft-hands: when ft-camd stops, wait for its new ring (15 s) and re-exec,
  instead of exiting and waiting out RestartSec.
- frametop-camd.service: RestartSec 1 s ramping to 5 s (it can't re-exec: it
  has dropped its capabilities under no_new_privs).
- ft-ringplay: --upper-skew / --upper-drift to replay that state.

Replay (1 core): old ft-hands 0 sets/s with the uppers 12 ms off; new 15.8
sets/s; in step 16.9 sets/s with no false alarm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 09:48:20 -06:00
DeeJanuzandClaude Opus 5.5 9d93983267 Input Settings: offer Keyboard + 3D pointer for devices that aren't connected
A sleeping Bluetooth keyboard has no nodes, so its kinds are unknown and
the role was hidden: it couldn't be picked until the device woke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:37:18 -06:00
DeeJanuzandClaude Opus 5.5 50a164e32b Input: Keyboard + 3D pointer role for keyboards with a touchpad
A keyboard with a touchpad has a keyboard node and a mouse node at one
Bluetooth address, so it got one role: 3D pointer, which took its keys
too (no key combinations, and Frametop's keyboard thought none was
connected). The new "combo" role splits the device by node: mouse nodes
are pointer, keyboard nodes passthrough (node_role, now module level and
tested in input/test/roles-test.py).

It's never a default, because the Swiftpoint Z3's keyboard node is its
extra buttons and nothing on the nodes tells the two apart. Input
Settings offers it for devices with both kinds; the Buttons and Keyboard
pages count such devices, and button capture skips their pass-through
keys (watch events now carry the node's role).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:27:52 -06:00
DeeJanuzandClaude Opus 5.5 b2a154ff5a Pause: a new desktop hears a pause; the gesture has a 3 s cooldown
On 2026-10-16 the pause gesture resumed Frametop and paused it again 2 s
later. An update then restarted the desktop and the relay: the relay came
back paused from its state file, but the new ft-screens never heard
"pause on", so the desktop showed while the 3D mouse stayed off.

- The relay passes the pid of each ft-screens "keyboard" report (sent
  every second) to GamePause.screens_seen, which tells a new ft-screens
  "pause on" while paused.
- The gesture is ignored for GESTURE_COOLDOWN (3 s) after it goes off,
  in place of the 1 s triple-press guard; dropped ones are logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:27:52 -06:00
295 changed files with 68196 additions and 244 deletions

No files matched your search

+11 -3
View File
@@ -41,6 +41,8 @@ You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or th
After the restart, Launch a program → Desktop opens the multi-screen desktop, with its screens arranged around where you're facing. Frametop Display Settings and Frametop Input Settings are in the desktop's application menu, under Settings. SteamOS's own single-screen desktop is still there, as Native Desktop in the same list.
The first time the desktop starts, Frametop Tour opens: a page at a time on the screens, the keyboard shortcuts, eye and hand tracking, spotwatch, floating windows, pins, profiles, and pausing for VR games, with buttons to try each one. Closing it, skipping it, or finishing it keeps it from opening again. It's in the application menu, in spotwatch, in SteamVR's Launch a program list, and in Frametop Input Settings' menu for whenever you want it again, and `tour/ft-tour --reset` has it open at the desktop's next start.
If you work in the desktop for long stretches, or leave the headset on a stand, open Frametop Display Settings → Power. Turn on Stay awake while plugged in: by default Steam puts the Frame to sleep after an hour without input, even while it charges. And choose when the displays turn off while the headset isn't used. SteamVR turns them off a few seconds after you take the headset off, but a stand or mount that covers the proximity sensor inside it makes the headset seem worn, and its displays stay on all night.
### Add a Bluetooth mouse or keyboard
@@ -68,7 +70,7 @@ If you work in the desktop for long stretches, or leave the headset on a stand,
| Meta+Shift+H in the desktop | Hides or shows all screens (also in the menu as Hide/Show Screens, and mappable). The Visibility & pins tab of Frametop Display Settings can instead show them only with the dashboard open, or while you look at your wrist |
| Tap Meta, on any keyboard | Opens the Steam menu in the SteamVR dashboard, or closes the dashboard, wherever you are. The desktop's launcher is still on the taskbar and Alt+F1. Change it in Frametop Input Settings (Keyboard page), where any key combination or modifier tap can do a Frametop or Steam action, open a profile, or run a command of your own |
| Switch a screen to Hidden (Frametop Display Settings, Visibility & pins → Screens shown) | Hides just that screen until you switch it back, whatever the other visibility settings say; new windows that would open on it float instead |
| Play a VR game | Frametop pauses so the game gets the headset to itself (see [Pause for VR games](#pause-for-vr-games)): the screens hide and your controllers stay in the game. Click both thumbsticks together twice to bring Frametop back. With the automatic pause off, the screens still hide, and the SteamVR dashboard or Meta+Shift+H shows them; to keep them visible over games, change During VR games on the Visibility & pins tab |
| Play a VR game | Frametop pauses so the game gets the headset to itself (see [Pause for VR games](#pause-for-vr-games)): the screens hide and your controllers stay in the game. Click both thumbsticks together twice to bring Frametop back (with one controller, click its thumbstick twice). With the automatic pause off, the screens still hide, and the SteamVR dashboard or Meta+Shift+H shows them; to keep them visible over games, change During VR games on the Visibility & pins tab |
Restarting the desktop (Restart desktop in Frametop Display Settings) closes its windows, but background work you started in it, such as servers, tmux sessions, or builds, keeps running.
@@ -92,6 +94,7 @@ Any desktop window can float in VR as a panel of its own. It stays a window of t
| Click the float button, left of Close in a window's title bar | The same. Float in VR is also in every window's menu (Alt+F3). Apps that draw their own title bar, like Chromium and Electron apps, don't have the button: use Meta+Shift+F |
| Right-click an app in the Application Launcher (or the taskbar) and pick Launch as Standalone | Starts the app with its window floating, where that app last floated, or in front of you the first time. From a terminal: `float/ft-float launch org.kde.dolphin`, or `float/ft-float run <command>` |
| Meta+scroll over a floating window | Scales it up or down |
| Set Apps from the SteamVR dashboard to Open as Frametop floating windows (Frametop Display Settings, Visibility & pins), then pick an app in the dashboard's Launch a program list | The app opens in the Frametop desktop, floating, instead of as a SteamVR window. When the desktop isn't running, Frametop is paused, or all floating windows are in use, it opens as a SteamVR window as before |
### Profiles
@@ -125,7 +128,7 @@ Frametop pauses while a VR game runs, so the game gets the headset's CPU and GPU
| Do this | To get this |
| --- | --- |
| Click both thumbsticks together, twice | Pauses Frametop, or brings it back, in a game or not. You hear a short sound. The game sees the clicks too |
| Click both thumbsticks together, twice | Pauses Frametop, or brings it back, in a game or not. You hear a short sound. The game sees the clicks too. With only one controller connected, click its thumbstick twice |
| Start a VR game | Frametop pauses, and comes back 5 seconds after the game ends. Bring it back during the game, and it stays on until that game ends |
| Map Pause/resume Frametop to a mouse button, key combination, or controller button | The same, from that button (Frametop Input Settings) |
@@ -256,6 +259,7 @@ A Plasma session runs nested inside ft-screens (`screens/`), a small Wayland com
| `float/` | Floating windows: ft-floatd and the KWin script that float a desktop window in VR. |
| `decoration/` | The desktop's window decoration: Breeze's look plus the float button. |
| `input/` | The input relay (Bluetooth mice and keyboards, button maps). |
| `keyboard/` | Frametop's keyboard: Full Keyboard for Steam Frame by TaiKeid, on the screens and in place of Steam's keyboard. See [keyboard/FRAMETOP.md](keyboard/FRAMETOP.md). |
| `pointer/` | The 3D mouse: SteamVR driver, helper service, and a probe tool. |
| `power/` | ft-powerd: turns the displays off while the headset isn't used. |
| `voice/` | Frametop Voice: dictation and wake words, recognised on the headset; the input relay types the text. See [voice/README.md](voice/README.md). |
@@ -310,7 +314,11 @@ Programs built in the `dev` container link against its glibc, which is newer tha
## License
MIT. See [LICENSE](LICENSE). The rendered hands' 3D models in `screens/assets/hands` (see its [README](screens/assets/hands/README.md); each folder has its licence) are from:
MIT. See [LICENSE](LICENSE).
Frametop's keyboard is [Full Keyboard for Steam Frame](https://github.com/TaiKeid/steam-frame-full-keyboard) by TaiKeid and contributors (MIT, Copyright (c) 2026 TaiKeid and contributors), in `keyboard/` with Frametop's changes. [keyboard/FRAMETOP.md](keyboard/FRAMETOP.md) lists what Frametop changed, and [keyboard/third_party/README.md](keyboard/third_party/README.md) the parts it includes.
The rendered hands' 3D models in `screens/assets/hands` (see its [README](screens/assets/hands/README.md); each folder has its licence) are from:
- the W3C Immersive Web Working Group's WebXR Input Profiles, `generic-hand` (MIT, Copyright (c) 2019 Amazon), the default;
- Babylon.js's hand meshes by Patrick Ryan, https://github.com/BabylonJS/Assets (CC BY 4.0, https://creativecommons.org/licenses/by/4.0/), unchanged;
+25 -3
View File
@@ -11,8 +11,10 @@ the dev container:
- Visibility (ft-screens): when the screens show (always, only with the SteamVR
dashboard open, while you look at a controller, or only when toggled), the wrist
angle within which a pinned screen shows, pinning each screen to a wrist or your
head, and whether the desktop's notifications go to the Steam session
(NOTIFY_FORWARD, session/ft-notifyfwd; its state from its log).
head, whether the desktop's notifications go to the Steam session
(NOTIFY_FORWARD, session/ft-notifyfwd; its state from its log), and whether apps from
the SteamVR dashboard's Launch a program list open as floating windows (DASHBOARD_APPS,
float/ft-dashroute).
- Layout: a preset (curved or flat, rows, distance, gap, height) or a named layout
saved from where the screens are, with a preview; arrange now; save the current
arrangement under a name; rename and delete; arrange automatically when the
@@ -64,6 +66,7 @@ FT_POWERD = "\0ft_powerd"
STEAM_SLEEP_AC_DEFAULT = 3600
SCALES = [0.75, 1.0, 1.25, 4 / 3, 1.5, 1.75, 2.0]
NOTIFY_FORWARD = ("auto", "on", "off") # session/ft-notifyfwd; frametop.conf's NOTIFY_FORWARD
DASHBOARD_APPS = ("steamvr", "frametop") # float/ft-dashroute; frametop.conf's DASHBOARD_APPS
NOTIFYFWD_LOG = "/tmp/frametop-notifyfwd.log" # its last line is its state (/tmp is the host's)
ROTATIONS = [("normal", "Landscape"), ("left", "Portrait"), ("right", "Portrait (flipped)")]
@@ -138,7 +141,10 @@ class Backend(QObject):
except ValueError:
return default
notify = conf.get("NOTIFY_FORWARD", "auto")
return {"screens": num("SCREENS", 2, int), "width": num("WIDTH", 1920, int),
dash = conf.get("DASHBOARD_APPS", "steamvr")
floats = num("FLOAT_SLOTS", 16, int)
return {"dashboardApps": dash if dash in DASHBOARD_APPS else "steamvr", "floatSlots": floats,
"screens": num("SCREENS", 2, int), "width": num("WIDTH", 1920, int),
"height": num("HEIGHT", 1080, int), "physWidth": num("PHYS_WIDTH", 1.6),
"notifyForward": notify if notify in NOTIFY_FORWARD else "off"}
@@ -428,6 +434,22 @@ class Backend(QObject):
write_conf_value("NOTIFY_FORWARD", mode)
self.changed.emit()
# --- apps from the SteamVR dashboard's Launch a program list (float/ft-dashroute) ---
@Property(str, notify=changed)
def dashboardApps(self):
return self._conf()["dashboardApps"]
@Property(int, notify=changed)
def floatSlots(self):
return max(0, min(16, self._conf()["floatSlots"]))
@Slot(str)
def setDashboardApps(self, mode):
"""ft-dashroute and ft-floatd read it as they go: no restart."""
if mode in DASHBOARD_APPS:
write_conf_value("DASHBOARD_APPS", mode)
self.changed.emit()
# --- ft-screens: visibility and pinning ---
@Property("QVariantMap", notify=changed)
def visibility(self):
+33
View File
@@ -1023,6 +1023,39 @@ Kirigami.ApplicationWindow {
onClicked: restartDialog.open()
}
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Apps from the SteamVR dashboard" }
Repeater {
model: [
{ value: "steamvr", text: "Open as SteamVR windows", help: "Apps picked in the dashboard's Launch a program list open as SteamVR's own windows, as without Frametop." },
{ value: "frametop", text: "Open as Frametop floating windows", help: "They open in the Frametop desktop instead, floating in front of you (where that app last floated), so drag and drop, the clipboard, spotwatch and profiles work with them. When the desktop isn't running, Frametop is paused, or all floating windows are in use, they open as SteamVR windows. Steam, SteamVR and Frametop's own layout entries always stay SteamVR's." }
]
delegate: ColumnLayout {
required property var modelData
spacing: 0
Controls.RadioButton {
text: modelData.text
enabled: backend.floatSlots > 0
checked: backend.dashboardApps === modelData.value
onToggled: if (checked) backend.setDashboardApps(modelData.value)
}
Controls.Label {
text: modelData.help
opacity: 0.7
font: Kirigami.Theme.smallFont
leftPadding: Kirigami.Units.gridUnit * 1.6
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
}
}
Controls.Label {
visible: backend.floatSlots === 0
text: "Floating windows are off (FLOAT_SLOTS=0 in ~/.config/frametop.conf), so these open in SteamVR."
wrapMode: Text.Wrap
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
}
}
}
}
+10
View File
@@ -178,6 +178,8 @@ Frametop's keyboard opens by itself for a text field on the desktop. The apps ru
The keyboard itself is ft-screens' own panel (`screens/keyboard.cpp`). We tried SteamVR's first (`ShowKeyboardForOverlay`), and on the Frame it doesn't fit a desktop. It's Steam's own panel (`valve.steam.gamepadui.keyboard`), which SteamVR mounts in the dashboard's scene, so with the dashboard closed it opened but wasn't drawn. Placing it in the room ourselves (`SetKeyboardTransformAbsolute`) made it show, but SteamVR moves it to whichever overlay the laser goes to and mounts it again, and while it's open the controllers switch to SteamVR's own laser. Our panel is an overlay like the screens' controls: any laser or the 3D mouse clicks it, nothing moves it, and its keys go out as key presses on ft-screens' seat rather than as text handed back to the input method. So nothing typed leaves ft-screens (a socket to the input method could be claimed by any local process, as the old `@frametop_keys` could), apps without text input (X11, Electron) take the keys too, and they mean what the desktop's keyboard layout says. It's drawn on the CPU into one of three shared buffers when a key's look changes; the labels come from stb_truetype, so the container needs no text rendering stack.
Full Keyboard for Steam Frame (TaiKeid's, `keyboard/`) replaces that panel when it runs: it has real layouts, input methods and themes, and it already replaces Steam's keyboard in Steam's fields. It types through gamescope (libei), which never sees the nested KWin, so in Frametop's mode (`--frametop`) its desktop typing goes through the input relay instead, which already types Frametop Voice's text there and knows where typing goes. The relay trusts it the way it trusts Voice: only this user's datagrams, and only from the address that said hello, since the control socket is abstract and anyone can send to it. Full Keyboard in turn takes word only from the relay's own name, and sends the desktop's typing only while that word is fresh, so a stopped relay never leaves keys addressed to a name another process could bind. Keys go as key presses on ft-screens' seat, as the old panel's did, so they work in every app and nothing typed leaves Frametop's processes; composed text goes through the desktop's layout like dictation's. ft-screens stays the one place that decides when a keyboard opens: while Full Keyboard says it runs, ft-screens passes its `vrkeyboard` commands to it through the relay, and opens its own panel otherwise.
The Frame controllers can be mapped like mouse buttons, but they aren't input devices on the host: they reach SteamVR over the headset's own radio, and no evdev or hidraw node exists for them. So only a SteamVR client can read them. Overlay apps normally get controller input only while they have input focus, which a background helper never has. SteamVR's experimental global action set priority (`steamvr/globalActionSetPriority`, "Enable global input from overlays") lets an overlay's action set receive input anyway, and takes the inputs it binds from the scene app. Binding every button would take them all from games, so the helper's action manifest puts each button in an action set of its own, and it activates only the sets of mapped buttons. The mapping itself stays in the relay, which does the action, so mice and controllers share one list of actions.
## The desktop session
@@ -206,6 +208,14 @@ Notifications sent through the notification portal (`org.freedesktop.portal.Noti
Run the isolated checks on the host with `/usr/bin/python3 session/test/test_notifyfwd.py`. Two private buses stand in for the desktop's and the user bus, with a fake Plasma (the watcher API, Inhibit) on one and a fake notification server on the other. They check forwarding with the hints' types, actions coming back, dismissed and expired notifications, replacements and closes mapping ids, `auto` skipping `simple_notif_daemon` until another server replaces it and giving Plasma its popups back when none is left, registering again after a Plasma restart, exiting with the desktop's bus, and doing nothing outside the Frametop desktop.
### Title bar buttons
Firefox and Chromium (and GTK apps) draw their own title bar, and they showed only a Close button in the desktop while showing minimize, maximize, and close when started from the SteamVR dashboard. Both take the buttons from GTK's decoration layout (`gtk-decoration-layout`), and GTK reads it from a different place in each. From the dashboard, the app runs in gamescope as an X11 client; GTK reads XSETTINGS there, gamescope has no XSETTINGS manager, and GTK's built-in default, `menu:minimize,maximize,close`, applies. In the desktop, the app is a Wayland client of KWin, and a Flatpak app reads the layout from the Settings portal, as `org.gnome.desktop.wm.preferences` `button-layout`. On Plasma, `kde-gtk-config` copies KWin's button order there and `xdg-desktop-portal-gtk` serves it (`kde-portals.conf` asks for Settings from `kde;gtk;`). SteamOS ships neither, and the KDE backend doesn't serve that key, so GTK fell back to GSettings in the sandbox and got GNOME's default: `appmenu:close` in Freedesktop runtimes, `menu:close` in GNOME 51. Neither the menu nor the app menu is drawn there, which left Close.
`session/ft-gtksettings` is a Settings portal backend (`org.freedesktop.impl.portal.Settings`, version 1) that serves only that key. It makes the layout from kwinrc's `ButtonsOnLeft` and `ButtonsOnRight` (KDecoration's defaults `MS` and `HIAX`), mapping the letters as `kde-gtk-config` does (`M` icon, `I` minimize, `A` maximize, `X` close; the rest have no GTK button), watches kwinrc and emits `SettingChanged` when the result changes, and writes the same value to the desktop's own GSettings (its dconf database is under `~/.config/frametop`), which GTK apps outside Flatpak read. xdg-desktop-portal 1.18.4 reads backends from a single folder, `XDG_DESKTOP_PORTAL_DIR` or `/usr/share/xdg-desktop-portal/portals`, and, with the variable set, reads `portals.conf` from that folder only (the user config folders are skipped). So the session script builds one in its runtime folder: links to the system's `.portal` files, `frametop.portal`, and a copy of `kde-portals.conf` with Settings from `kde;frametop;`. The portal merges `ReadAll` across backends and takes `Read` from the first that has the key, so KDE's appearance settings come through unchanged. The helper's D-Bus service file is in `$XDG_RUNTIME_DIR/frametop/share/dbus-1/services`, a folder the script puts on `XDG_DATA_DIRS`, so only the desktop's bus can start it.
Run the isolated checks on the host with `/usr/bin/python3 session/test/test_gtksettings.py`: a private bus, a temporary kwinrc, and GSettings' memory backend. They check the default and a configured order, the `ReadAll` filter, `NotFound` for other keys, the signal on a change and none on a save that changes nothing, the version property, and exiting with the bus.
### Other session behavior
Steam, not systemd, suspends the Frame: after `system_idle_suspend_ac_sec` (an hour by default) without input on AC power, it logs `Switching to power state: k_ESystemPowerState_Sleep` and suspends, even while charging. It's a Steam setting (Settings → Power → When Plugged In and Idle → Sleep after), which the Stay awake while plugged in switch in Frametop Display Settings sets to Never. SteamVR's standby, which turns the displays off when the headset comes off, is separate; see below.
+33 -10
View File
@@ -32,6 +32,8 @@ kwriteconfig6 --file ~/.config/frametop/kdeglobals --group KDE --key AnimationDu
The desktop's notifications can show outside it, in the Steam session. Plasma there runs on the desktop's own D-Bus, so its popups only appear on the desktop's screens. `session/ft-notifyfwd` (Plasma's autostart; log: `/tmp/frametop-notifyfwd.log`) hands each one to the notification server on the user bus, the one the Steam session's apps use, for example a VR notification server that draws them as panels. Its buttons work: a click there reaches the app as if made in Plasma, and dismissing it there closes it in Plasma. While it forwards, Plasma's own popups are off (do not disturb, held by "frametop"), and Plasma's notification history still has every one. `NOTIFY_FORWARD` in `~/.config/frametop.conf` (or Notifications on the Visibility & pins tab of Frametop Display Settings) sets it: `auto` (the default) forwards only when the user bus's server isn't SteamOS's own `steam_notif_daemon`, which hands notifications to a Steam client that can't show them on the Frame, so with stock SteamOS nothing changes; `on` forwards to any server, SteamOS's too, where they're lost (it's in the file only; the settings app shows it while it's set); `off` keeps them in the desktop. Restart the desktop after a change. Notifications Flatpak apps send through the notification portal go along too. KDE's inline reply shows as a plain Reply button there; see [design.md](design.md#notifications-outside-the-desktop).
Apps that draw their own title bar, like Firefox and Chromium, show the same buttons as KWin's title bars (minimize, maximize, and close unless changed in System Settings → Window Decorations). `session/ft-gtksettings` hands them KWin's button order through the Settings portal; D-Bus starts it in this desktop (log: `/tmp/frametop-gtksettings.log`). Frametop's float button has no GTK counterpart, so those apps use the key or the window menu to float (see [Floating windows](#floating-windows)), or KWin's title bar instead of their own: Use system title bar and borders in Chromium's tab strip menu, Title Bar in Firefox's Customize Toolbar. See [design.md](design.md#title-bar-buttons).
Three of the system's autostart programs don't start in this desktop: Discover's update notifier (`org.kde.discover.notifier`), which starts Discover to check for updates, IBus (`ibus`), which can't reach the desktop's apps because KWin's input method is `input/ft-textinput`, and Steam (`steam`), which is already running. The session script puts copies with `Hidden=true` in `~/.config/frametop/autostart` once (marked in `frametoprc`; Steam's was added later and is hidden once on desktops that already had the other two), and skips a name you already have a file for. Delete a copy to start that program again.
Settings are in two files, and Frametop Display Settings edits both. The screens (resolution, width in metres, scale, curve, which one has the taskbar) and their layout are in `~/.config/frametop-layout.json`. The backend, remote desktop, and pointer settings are in `~/.config/frametop.conf`; `session/frametop.conf.example` lists every key.
@@ -74,6 +76,13 @@ Input from the lasers reaches KWin through ft-screens' own seat. Keys come from
Frametop's keyboard opens by itself when a text field on the desktop gets focus, and stays open until its Close key, a layout reset, or a mapped button closes it (or, with Keep it open off in Frametop Input Settings, until the text field loses focus). While the Steam menu (the dashboard) or Steam's own keyboard is up, it steps aside, and it comes back where it was when they're gone; one asked for meanwhile appears then. In the "only with the dashboard" visibility mode, the dashboard doesn't count. It doesn't open without a head pose (the headset in standby). It's a panel of keys (a US laptop layout, with Esc where Caps Lock would be, arrows, and a Close key) that ft-screens shows 0.7 m in front of you and below your eyes, facing you. It stays where it opened, and its grab bar (the pill along the top) moves it like a screen's. Type on it with a controller's laser or the 3D mouse. Shift, Ctrl and Alt latch for the next key, and a held key repeats. KWin starts `input/ft-textinput` as the desktop's input method, and KWin activates it whenever the focused app turns on text input for a field. It tells the relay (`textfield 1` or `0`), the relay decides by the Keyboard setting in Frametop Input Settings, and ft-screens opens the keyboard for the screen that has keyboard focus (`vrkeyboard show`, `hide`, or `toggle` from a mapped button). Its keys reach the focused screen as key presses, so it works in every app, but only apps that use Wayland text input (Qt, GTK, Firefox) open it by themselves; Chromium, Electron and X11 apps need the button. The session drops the `QT_IM_MODULE=xim` and `GTK_IM_MODULE=xim` that the gamescope session sets, or Qt and GTK apps wouldn't use Wayland text input either.
Frametop's keyboard is Full Keyboard for Steam Frame, by TaiKeid (`keyboard/`, MIT; [keyboard/FRAMETOP.md](../keyboard/FRAMETOP.md)), when it runs: the user service `frametop-keyboard.service` starts it with SteamVR (`framekeyboard --frametop`). It has full layouts, languages and input methods, themes, and replaces Steam's own keyboard. The same things open and close it as the panel above: while it runs, ft-screens passes its `vrkeyboard` commands to it through the input relay, and the panel above is the fallback for when it isn't running. It shows over the screens with the dashboard closed while Frametop asked for it, and in the dashboard as always. Its typing goes where typing goes: on the desktop, the relay types it on ft-screens' seat (keys by their place, so pick the desktop's layout in it too; text the desktop's layout has no key for, like Chinese or Japanese, isn't typed there yet), and Steam and games get it through gamescope as before. A click on its panel leaves typing where it was. Its settings are in `~/.config/frametop-keyboard`.
```
keyboard/run.sh status | restart | log [lines] | install | uninstall
FULL_KEYBOARD_TESTS=1 keyboard/build.sh # build it with its tests
```
KWin's nested backend doesn't undo a screen's scale on pointer input, so ft-screens divides panel positions (in pixels) by it. `ft-layout` sends it each screen's scale as KWin reports it (`scale N s`) whenever it applies scales: at desktop start and from Frametop Display Settings. A scale changed only in Plasma's own display settings is put back to the Frametop layout's the next time `ft-layout` runs.
ft-screens listens for datagrams on the abstract socket `@ft_screens` and replies to the sender:
@@ -81,7 +90,7 @@ ft-screens listens for datagrams on the abstract socket `@ft_screens` and replie
```
place N x y z yaw pitch roll width N metres curve N radius|on|off
pin N|all left|right|head [matrix] unpin N|all size N w h
get N screens head state key code value scale N s vrkeyboard show|hide|toggle|close
get N screens head state key code value scale N s vrkeyboard show|hide|toggle|close fullkb 0|1
visibility always|dashboard|gesture|toggle wrist degrees gesture left|right degrees
hide | show | toggle controllers always|outside_games|dashboard ingames hide|visible pause on|off|state
conceal N|all reveal N|all concealed cutouts on|off|state cutouts predict on|off cutouts lead ms
@@ -110,7 +119,8 @@ The relay also owns the volume keys, on every device that has them, the headset'
desktops.sh relay install # enable it (starts with the next reboot or SteamVR start)
desktops.sh relay status | log | uninstall
input/input-relay.py --no-grab # try it without taking devices from SteamVR
input/test/keys-test.py # key combinations, modifier taps, and what reaches the desktop, against fake devices (safe next to the live relay)
input/test/keys-test.py # key combinations, modifier taps, dictation's and Full Keyboard's typing, and what reaches the desktop, against fake devices (safe next to the live relay)
input/test/roles-test.py # each node's role, Keyboard + 3D pointer included, offline
steam/ft-steam menu # what Open Steam menu does; ft-steam check: Steam's UI still has the calls
```
@@ -141,14 +151,14 @@ The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `P
## Frametop Input Settings
A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has ten pages:
A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has ten pages, and Frametop Tour at the end of its menu:
- Devices lists every USB and Bluetooth mouse and keyboard, with a light that flashes when the device is used. Each device gets a role: 3D pointer (grabbed, drives the pointer; the default for anything with a mouse), Pass through (grabbed only while typing goes to the desktop; the default for keyboards, whose key combinations work everywhere), or Ignore. A device is identified by its Bluetooth address, or its USB ids and name, so all of its input nodes share one role. Forget drops everything saved for a device.
- Devices lists every USB and Bluetooth mouse and keyboard, with a light that flashes when the device is used. Each device gets a role: 3D pointer (grabbed, drives the pointer; the default for anything with a mouse), Pass through (grabbed only while typing goes to the desktop; the default for keyboards, whose key combinations work everywhere), Keyboard + 3D pointer (for a keyboard with a touchpad, offered for devices that have both: its touchpad drives the pointer and its keys pass through, as if they were two devices), or Ignore. A device is identified by its Bluetooth address, or its USB ids and name, so all of its input nodes share one role, except with Keyboard + 3D pointer. That one is never a default: the Swiftpoint Z3 also has a mouse and a keyboard node, and its keyboard node is its extra buttons. Forget drops everything saved for a device.
- Buttons maps a pointer device's buttons. Choose Capture a button, press the button or key, then pick an action: a click, back, scroll, toggle dashboard, recenter, pointer on or off, head follow on or off, gaze pointer on or off, gaze precision, gaze drag, gaze quick check, faster or slower, reset the screen layout, hide or show the screens, open or close the keyboard, float a window in VR or put it back, put all floating windows back, pause or resume Frametop ([Pausing for VR games](#pausing-for-vr-games)), Open profile NAME (one per profile, [profiles.md](profiles.md)), pass the key through, or nothing. Devices with saved mappings are listed even while they're asleep.
- Controllers maps the Frame controllers' buttons (every button but the system button) to the same actions, except passing a key through and the gaze actions: gaze mode is a mouse and keyboard feature ([gaze-controllers.md](gaze-controllers.md)). Capture a button and press it on a controller, or pick it from the list. The controllers aren't input devices on the host; only SteamVR sees them. So the pointer helper reads them with SteamVR input (`pointer/helper/vrbuttons.h`, `pointer/helper/actions/`) and sends presses to the relay (`vrbtn right/a 1`), which does the mapped action. The helper only takes the buttons that are mapped (the relay tells it with `vrbind`), at an overlay-global priority, and only while no game (scene application) runs, so games keep every button; with In games on (`controller_in_games`), a mapped button is taken from games too. That needs SteamVR's "Enable global input from overlays (Experimental)" setting (`steamvr/globalActionSetPriority`), which the page's Global input switch turns on and off. Mappings are saved as `controller_buttons` in `~/.config/frametop-input.json`.
- Game optimization has the pause for VR games: its state with Pause now or Resume, whether VR games pause Frametop by themselves, the controller gesture (one or two buttons, pressed once or twice; one button always takes two presses), what happens to the desktop, and the sound. They're saved as `pause_auto`, `pause_gesture`, `pause_desktop`, and `pause_sound` in `~/.config/frametop-input.json`. See [Pausing for VR games](#pausing-for-vr-games).
- Keyboard sets when Frametop's keyboard opens: whenever a text field is selected; only while no pass-through keyboard is connected (the default; keyboards other programs make through uinput don't count); only with a mouse or controller button mapped to Open/close keyboard; or never, which turns the button off too. Keep it open (on by default, `vr_keyboard_persist`) leaves it open after the text field loses focus. The mode is saved as `vr_keyboard` in `~/.config/frametop-input.json`, and the page lists the keyboards that count as connected. Its Key combinations section maps modifiers plus a key, or one modifier tapped on its own, on any keyboard, to any action but passing a key through or nothing, or to Run a command…: a command line the input relay runs with `sh -c` when you press the keys (`command:CMD`). The command runs as the relay's user service, outside the desktop's session, with `layout/`, `float/` and `steam/` on its `PATH` (so `ft-layout use Work` or `ft-float launch org.kde.dolphin` work as they are), and its output goes to the relay's journal. The gaze clicks (Gaze left click and Gaze right click) only go on key combinations. The defaults are a Meta tap (open the Steam menu, or close the dashboard), Meta+J (gaze left click), Meta+K (gaze right click), Meta+Shift+F (float window in VR or put it back), and Meta+Alt+Tab and Meta+Alt+Shift+Tab (spin the panels: every screen and floating window turns about your head, so the next one on the right or left comes to the front; ft-screens' `spin next|prev|<degrees>`); remove them or add others there. With Frametop Voice installed, a Right Ctrl tap starts and stops dictation too. A tap is a press with no other modifier held, then a release with no other key, mouse button, or scroll in between; a bound one sends the desktop F24 before the release, so Plasma's launcher doesn't open on it. Tapping a left modifier binds either side's; tapping a right one (Right Ctrl) binds that key alone. The combination's last key isn't typed, and the modifiers still reach the app; while typing goes to Steam rather than the desktop, keyboards aren't grabbed, so Steam or the game sees the keys too. They're saved as `key_bindings` in the same file; a file with its own list, even an empty one, gets no defaults.
- Voice has Frametop Voice's settings (see Voice dictation): on or off, the dictation keys (set on the Keyboard page), the controller buttons and their mode, typing as you talk, sounds, and the wake word and its phrases. They're saved in `~/.config/frametop-voice.json`, and the service applies them within 2 seconds.
- Voice has Frametop Voice's settings (see Voice dictation): on or off, the dictation keys (set on the Keyboard page) and whether they're tapped or held, the controller buttons and their mode, the language and whisper model, typing as you talk, sounds, and the wake word and its phrases. They're saved in `~/.config/frametop-voice.json`, and the service applies them within 2 seconds.
- Pointer has a Head follow switch and sliders for the pointer settings, which apply immediately, and a Recenter button.
- Ignored panels lists the SteamVR overlays that are showing, grouped by app (the first two parts of the overlay key, such as `sasaken.frame-perf-overlay`), from the pointer helper (`overlays`). Tick a panel, or Ignore the whole app, and the pointer passes through it to what's behind. It's for panels you only look at, like a performance overlay that follows your view. The list is saved as `POINTER_IGNORE` in `~/.config/frametop.conf`: comma-separated overlay keys, where a shell pattern like `vendor.app*` covers a whole app, including panels it opens later. The helper reloads at once. Frametop's own screens aren't listed, and entries for apps that aren't open are listed below, to remove.
- Gaze has the gaze pointer switch (on now and from now on; a mapped button toggles it until the helper restarts), what the mouse's left button and movement do, the gaze dot, the eye tracker and eye bias, the gaze mode sliders, the gaze service's state (headset, samples per second, how often the tracker is losing each eye, the calibration, the nudges learned), and Quick check, Calibrate, and Check headset fit (each in a panel in the headset), Reload calibration, and Forget nudges. The gaze probe, a development tool, is in the page's overflow menu.
@@ -210,6 +220,8 @@ A desktop window can float in VR as a panel of its own, away from the screens. M
`float/ft-floatd` does this. It runs inside the desktop's Plasma session (log: `/tmp/frametop-floatd.log`), loads the KWin script `float/frametop-float.js`, and moves each floating window to a spare KWin output of its own, which ft-screens shows as a panel cropped to the window. The settings are in `~/.config/frametop.conf`: `FLOAT_SLOTS` is how many windows can float at once (16, the most; 0 turns floating off; restart the desktop after a change), and `FLOAT_MARGIN` the pixels around each window on its output, so menus have room past its edges (300). Each app's last floating place, size, and scale are kept in `~/.config/frametop-float.json` by desktop file name, relative to the primary screen, so they move with the screens. `FT_FLOAT_DEBUG=1` in ft-floatd's environment logs every event from the KWin script.
Apps from the SteamVR dashboard: with `DASHBOARD_APPS=frametop` (Frametop Display Settings, Visibility & pins, "Apps from the SteamVR dashboard"; the default is `steamvr`), an app picked in the dashboard's Launch a program list opens floating in the desktop, as Launch as Standalone does, not as a SteamVR window. The list is Steam's (its VR menu), and picking an app runs `SteamClient.Apps.LaunchNonSteamApp(cmdline)` in Steam's UI. `float/ft-dashroute`, which the session starts next to ft-floatd (log: `/tmp/frametop-dashroute.log`), wraps that call over Steam's DevTools port, 127.0.0.1:8080 (`float/dash-route.js`), and hands the command line to ft-floatd (`dashlaunch`). ft-floatd finds the app's desktop file by its command (Steam's command line is the Exec line without its field codes) and launches it floating, or answers that it stays SteamVR's: when the setting is off, Frametop is paused, every floating window is in use (counting launches still waiting for their window), or it's Steam, SteamVR, a desktop, or one of Frametop's layout entries (`STEAMVR_ONLY`). Then Steam's own launch runs after all. The wrapper only hands launches over while ft-dashroute's heartbeat is fresh (every 3 seconds; stale after 10), so with the desktop closed every launch is Steam's own, and ft-dashroute turns it off as it exits. The setting applies at once. `float/ft-dashroute status` shows whether the wrapper is in; `scripts/update-check.py` checks that Steam's UI still launches that way.
With floating on, the session gives the desktop's windows Frametop's own decoration (`decoration/`): Breeze's look plus the float button. Apps that draw their own title bar, like Chromium and Electron apps, don't have the button. `decoration/apply.sh` puts a changed copy into the running desktop, and `decoration/apply.sh --off` goes back to Breeze until the next desktop start.
```
@@ -223,6 +235,7 @@ float/ft-float list # the spare outputs and what floats on the
float/ft-float launch firefox action=new-private-window # one of the app's own actions, floating
float/ft-float launch org.kde.dolphin screen=2 # its first window docked on screen 2
float/ft-float activate ID # make a window the active one, unminimized
float/ft-float dashlaunch CMDLINE # what ft-dashroute sends for a dashboard launch: ok, or steamvr REASON
```
## Spotwatch
@@ -236,6 +249,16 @@ spotwatch/test/spotwatch-test.py # the ranking and the search against a fake f
spotwatch/tools/render.py OUT.png # the panel's picture, without a headset
```
## Frametop Tour
`tour/ft-tour` (in the app menu, spotwatch, and SteamVR's Launch a program list as Frametop Tour, and in Frametop Input Settings' menu) walks through Frametop a page at a time: the screens and the 3D mouse, the key combinations the relay has now (`key_bindings`, as Frametop Input Settings shows them) and macros, gaze mode with Meta+J and Meta+K, hand tracking with and without gaze, spotwatch, floating windows (with the Apps from the SteamVR dashboard choice), pinning, profiles, pausing for VR games, and the rest. Each page has buttons that try things (spotwatch, a spin, a calibration or quick check) or open their settings page (`FT_INPUT_PAGE`, `FT_DISPLAY_PAGE`); the gaze and hands pages have a button to practise clicking on. A part that isn't installed says how to install it. A Kirigami app with a Python backend (`tour/ft_tour.py`), in the container like Frametop Input Settings; `FT_TOUR_PAGE=ID` opens it on a page.
The desktop's autostart runs `ft-tour --first-run` (`frametop-tour.desktop`, written by `session/frametop-session.sh`; log: `/tmp/frametop-tour.log`), which opens it only while `~/.local/state/frametop/tour.json` is missing or from an older tour (`TOUR_VERSION`). Closing the tour, Skip the tour, and Finish write that file, unless Show this tour when the desktop starts is ticked on its last page. `ft-tour --reset` removes it.
```
tour/test/tour-test.py # the flag, the wrapper's --first-run and --reset, and the pages for each set of installed parts (offline)
```
## Displays off and sleep
SteamVR turns the displays off a few seconds after the headset's proximity sensor says it came off. A stand or display mount that covers the sensor makes the headset seem worn, so its displays stay on, and Steam, which then counts someone as present, never puts it to sleep either.
@@ -270,7 +293,7 @@ Resuming starts again only what pausing stopped, and plays a second sound. The p
Ways to pause and resume:
- The controller gesture, by default both thumbsticks clicked together twice: both go down within 0.3 seconds of each other, and the second time within 0.7 seconds of the first. The relay reads it from vrserver's web socket (`input/vrws.py`), which works whatever has input focus and takes nothing from the game, so the game sees the clicks too. The Game optimization page changes it: one or two of the buttons the Controllers page lists, pressed once or twice (one button always takes two), or none.
- The controller gesture, by default both thumbsticks clicked together twice: both go down within 0.3 seconds of each other, and the second time within 0.7 seconds of the first. Once it goes off, it's ignored for 3 seconds, so a repeat or a triple press doesn't toggle Frametop straight back. The relay reads it from vrserver's web socket (`input/vrws.py`), which works whatever has input focus and takes nothing from the game, so the game sees the clicks too. With only one controller connected, a gesture with a button on each controller is that controller's button alone, clicked twice (`one_controller`), so the default works with one controller too. The Game optimization page changes it: one or two of the buttons the Controllers page lists, pressed once or twice (one button always takes two), or none.
- The Pause/resume Frametop action, on a mouse button, a key combination, or a controller button (outside games, like every mapped controller button).
- VR games, with Pause while a VR game runs on (`pause_auto`, the default). The pointer helper tells the relay when a scene app starts and ends (`vrgame 1|0`, repeated every 5 seconds). A game starting pauses Frametop. A pause that starts while a game runs ends 5 seconds after the game does, unless another game starts first. Resumed during a game, Frametop stays on until that game ends. A pause that starts outside a game lasts until you resume. Flatscreen games aren't scene apps, so they don't pause it.
- From a terminal or a script:
@@ -283,15 +306,15 @@ input/test/pause-test.py # the gesture and the automatic pause, offlin
input/test/pause-buttons-test.py # a click held into a pause comes up, offline
```
The state outlives a relay restart, in `/run/user/UID/frametop-pause.json`. A SteamVR restart while paused starts the gaze service with it, and the relay stops it again when the pointer helper comes back.
The state outlives a relay restart, in `/run/user/UID/frametop-pause.json`. A desktop that starts while paused (a restart, an update) is told so the first time it reports to the relay, so it hides too instead of showing with the 3D mouse off. A SteamVR restart while paused starts the gaze service with it, and the relay stops it again when the pointer helper comes back.
## Voice dictation
Frametop Voice (`voice/`, [voice/README.md](../voice/README.md)) types what you say where you type, recognised on the headset; nothing leaves it. `voice/ft-voiced` runs in Frametop's container as `frametop-voice.service` (installed by `voice/run.sh install`, which `install.sh` offers, yes by default), starting at login. It records with `pw-record` and transcribes live with Moonshine on the CPU; `frametop-voice-whisper.service` (whisper.cpp on the GPU) runs only while the wake word is on, for its commands. Its models, about 320 MB, are downloaded on install into `~/.local/share/frametop/voice/models` (`voice/models.sh`: fixed URLs, checked by sha256).
Frametop Voice (`voice/`, [voice/README.md](../voice/README.md)) types what you say where you type, recognised on the headset; nothing leaves it. `voice/ft-voiced` runs in Frametop's container as `frametop-voice.service` (installed by `voice/run.sh install`, which `install.sh` offers, yes by default), starting at login. It records with `pw-record` and transcribes English live with Moonshine on the CPU; `frametop-voice-whisper.service` (whisper.cpp on the GPU, `voice/whisper-run` loads the model the settings choose) runs only while something uses it: wake word commands, or a language other than English. Its models, about 320 MB, are downloaded on install into `~/.local/share/frametop/voice/models` (`voice/models.sh`: fixed URLs, checked by sha256).
The input relay does the keyboard side. Dictation's keys are key combinations with the actions `dictate` (a press starts or stops) and `dictate_hold` (records while held); with Frametop Voice installed, the default key combinations add a Right Ctrl tap (`"97": "dictate"`, Right Ctrl alone: a right-hand modifier's own code is that key's tap only). Like every tap, it doesn't count with another modifier held, or with a key, click or scroll in between. The relay sends `toggle|start|stop <source>` to `@frametop_voice`, and dictation works while Frametop is paused. ft-voiced sends its text back as `type TEXT` on `@frametop_relay`; the relay types it, one key every 4 ms on a US layout, to ft-screens while typing goes to the desktop, otherwise on its virtual keyboard for Steam and games, and holds a character back while a modifier is held. Both sockets take these only from their own user. Frame controller buttons (left d-pad down, held, by default) are read by ft-voiced itself from vrserver's web socket (`input/vrws.py`), so games still get them.
The input relay does the keyboard side. Dictation's keys are key combinations with the actions `dictate` (a press starts or stops) and `dictate_hold` (records while held; a lone modifier held to talk is kept from the desktop until another key makes it a shortcut, which drops that recording with `cancel keyboard quiet`); with Frametop Voice installed, the default key combinations add a Right Ctrl tap (`"97": "dictate"`, Right Ctrl alone: a right-hand modifier's own code is that key's tap only). Like every tap, it doesn't count with another modifier held, or with a key, click or scroll in between. The relay sends `toggle|start|stop|cancel <source> [quiet]` to `@frametop_voice`, and dictation works while Frametop is paused. ft-voiced sends its text back as `type TEXT` on `@frametop_relay`; the relay types it, one key every 4 ms, to ft-screens on the desktop's keyboard layout (`~/.config/frametop/kxkbrc`, through libxkbcommon) while typing goes to the desktop, otherwise on its virtual keyboard with a US layout for Steam and games, and holds a character back while a modifier is held. Both sockets take these only from their own user. Frame controller buttons (left d-pad down, held, by default) are read by ft-voiced itself from vrserver's web socket (`input/vrws.py`), so games still get them.
Frametop Input Settings' Voice page turns it on and off and sets the controller buttons and their mode (hold or press to toggle), typing as you talk, sounds, and the wake word (off by default) and its phrases, in `~/.config/frametop-voice.json`; the keys are on its Keyboard page. Wake word commands that run a program, and advanced settings, are edited in that file by hand (voice/README.md).
Frametop Input Settings' Voice page turns it on and off and sets whether the dictation keys are tapped or held, the controller buttons and their mode (hold or press to toggle), the language and whisper model (one not yet there is downloaded, `voice/models.sh model NAME`), typing as you talk, sounds, and the wake word (off by default) and its phrases, in `~/.config/frametop-voice.json`; the keys are on its Keyboard page. Wake word commands that run a program, and advanced settings, are edited in that file by hand (voice/README.md).
```
voice/build.sh && voice/run.sh install
+34
View File
@@ -0,0 +1,34 @@
// Put into Steam's SharedJSContext by float/ft-dashroute. The SteamVR dashboard's "Launch a
// program" list (Steam's VR menu) runs SteamClient.Apps.LaunchNonSteamApp(cmdline) when an app
// is picked. This wraps it: while ft-dashroute is there and the setting is on, the command line
// goes to ft-dashroute (its CDP binding, window.__ftDashLaunch), which floats the app in the
// Frametop desktop, or, if it can't, runs Steam's own launch after all (state.original). With
// ft-dashroute gone (no heartbeat for STALE_MS) every launch is Steam's own again.
(() => {
const VERSION = 1;
const STALE_MS = 10000;
const apps = SteamClient.Apps;
const state = window.__ftDashRoute || (window.__ftDashRoute = {});
if (!state.original || !apps.LaunchNonSteamApp.__ftDashRoute) {
state.original = apps.LaunchNonSteamApp; // Steam's own, not a wrapper from before
}
state.version = VERSION;
state.alive = Date.now();
state.on = true;
const wrapper = function (cmdline, ...rest) {
const s = window.__ftDashRoute;
if (s && s.on && Date.now() - s.alive < STALE_MS && typeof cmdline === "string"
&& typeof window.__ftDashLaunch === "function") {
try {
window.__ftDashLaunch(JSON.stringify({ cmdline }));
return;
} catch (e) {
// the binding went with its DevTools session: Steam's own launch below
}
}
return state.original.call(apps, cmdline, ...rest);
};
wrapper.__ftDashRoute = true;
apps.LaunchNonSteamApp = wrapper;
return "dashboard apps open in Frametop (v" + VERSION + ")";
})()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""ft-dashroute: apps picked in the SteamVR dashboard's Launch a program list open as Frametop
floating windows, not as SteamVR windows of their own (DASHBOARD_APPS=frametop in
~/.config/frametop.conf: Frametop Display Settings, Visibility & pins tab, "Apps from the
SteamVR dashboard"). The default, DASHBOARD_APPS=steamvr, leaves them to SteamVR.
The list is in Steam's own UI (its VR menu), and picking an app runs
SteamClient.Apps.LaunchNonSteamApp(cmdline) in Steam's SharedJSContext, which Frametop reaches
over the Chrome DevTools Protocol on 127.0.0.1:8080 (steam/steamui.py). With the setting on,
this puts dash-route.js there: it wraps that call and hands the command line to this program
(a CDP binding, Runtime.bindingCalled), which asks ft-floatd ("dashlaunch CMDLINE",
@frametop_float). ft-floatd starts the app with its first window floating, or answers why it
should stay SteamVR's (the setting is off, Frametop is paused, it's Steam or one of Frametop's
own commands, or every floating window is in use), and then Steam's own launch runs after all.
The wrapper only sends launches here while this program's heartbeat (every HEARTBEAT seconds)
is fresh, so a launch is Steam's own whenever the desktop isn't running.
Runs inside the Frametop desktop's session, next to ft-floatd (its autostart entry, written by
session/frametop-session.sh when windows can float). It looks at the setting every HEARTBEAT
seconds, so a change applies at once; with it off, it doesn't touch Steam at all.
ft-dashroute keep the wrapper in Steam's UI while the setting is on
ft-dashroute status the setting, and whether the wrapper is in
"""
import json
import os
import re
import select
import signal
import socket
import sys
import time
import urllib.request
HERE = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "steam"))
sys.path.insert(0, os.path.join(HERE, "..", "layout"))
import steamui # noqa: E402 (Frametop's small CDP client)
import ft_layout # noqa: E402 (frametop.conf)
PATCH = os.path.join(HERE, "dash-route.js")
BINDING = "__ftDashLaunch"
FLOAT = "\0" + os.environ.get("FT_FLOAT_SOCKET", "frametop_float")
HEARTBEAT = 3.0 # seconds; the wrapper stops sending launches here after 10 s without one
VERSION = int(re.search(r"const VERSION = (\d+);", open(PATCH).read()).group(1))
BEAT = ("(() => { const s = window.__ftDashRoute; if (!s || s.version !== %d || !s.original) return 'missing';"
" s.alive = Date.now(); s.on = true; return 'ok'; })()" % VERSION)
OFF = "(() => { const s = window.__ftDashRoute; if (s) s.on = false; return 'off'; })()"
STATUS = ("JSON.stringify(window.__ftDashRoute ? {version: window.__ftDashRoute.version, on: window.__ftDashRoute.on,"
" age: Date.now() - window.__ftDashRoute.alive} : null)")
def log(*args):
print(time.strftime("%H:%M:%S"), *args, flush=True)
def wanted():
return ft_layout.read_conf().get("DASHBOARD_APPS", "steamvr") == "frametop"
def shared_context():
"""The SharedJSContext's debugger URL, or None (Steam's UI isn't up)."""
try:
with urllib.request.urlopen(f"http://127.0.0.1:{steamui.CDP_PORT}/json", timeout=3) as r:
targets = json.load(r)
except (OSError, ValueError):
return None
return next((t.get("webSocketDebuggerUrl") for t in targets if t.get("title") == "SharedJSContext"), None)
class Cdp:
"""One DevTools session: calls by id, and the binding's calls as they come."""
def __init__(self, url):
self.ws = steamui._WebSocket(url)
self.next_id = 1
self.calls = [] # command lines the wrapper sent, not handled yet
def close(self):
self.ws.close()
def _message(self):
msg = json.loads(self.ws.recv())
if msg.get("method") == "Runtime.bindingCalled" and msg.get("params", {}).get("name") == BINDING:
try:
cmdline = json.loads(msg["params"].get("payload") or "{}").get("cmdline")
except ValueError:
cmdline = None
if isinstance(cmdline, str):
self.calls.append(cmdline)
return msg
def call(self, method, **params):
n, self.next_id = self.next_id, self.next_id + 1
self.ws.send(json.dumps({"id": n, "method": method, "params": params}))
while True:
msg = self._message()
if msg.get("id") == n:
return msg.get("result", {})
def evaluate(self, expression):
result = self.call("Runtime.evaluate", expression=expression, returnByValue=True)
if "exceptionDetails" in result:
d = result["exceptionDetails"]
raise steamui.SteamUnreachable(d.get("exception", {}).get("description") or d.get("text", "error"))
return result.get("result", {}).get("value")
def wait(self, seconds):
"""Read what comes for up to `seconds`, then return the launches that came."""
end = time.monotonic() + seconds
while not self.calls:
left = end - time.monotonic()
if left <= 0:
break
if self.ws.buf or select.select([self.ws.sock], [], [], left)[0]:
self._message()
calls, self.calls = self.calls, []
return calls
def ask_floatd(cmdline):
"""ft-floatd's answer to "dashlaunch CMDLINE": "ok", or "steamvr REASON"."""
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
try:
s.bind("")
s.settimeout(5)
s.sendto(f"dashlaunch {cmdline}".encode(), FLOAT)
return s.recv(4096).decode(errors="replace")
except OSError as e:
return f"steamvr ft-floatd didn't answer ({e})"
finally:
s.close()
def inject(cdp):
cdp.call("Runtime.addBinding", name=BINDING)
with open(PATCH) as f:
log(f"Steam's UI: {cdp.evaluate(f.read())}")
def route(cdp, cmdline):
reply = ask_floatd(cmdline)
if reply.startswith("ok"):
log(f"floating: {cmdline}")
return
log(f"SteamVR window: {cmdline} ({reply.removeprefix('steamvr ').strip()})")
cdp.evaluate(f"window.__ftDashRoute.original.call(SteamClient.Apps, {json.dumps(cmdline)})")
def session(url):
"""Keep the wrapper in this page while the setting is on; returns when it's off."""
cdp = Cdp(url)
try:
inject(cdp)
while True:
for cmdline in cdp.wait(HEARTBEAT):
route(cdp, cmdline)
if not wanted():
cdp.evaluate(OFF)
log("setting off: dashboard apps open in SteamVR again")
return
if cdp.evaluate(BEAT) == "missing": # Steam's UI reloaded
inject(cdp)
finally:
cdp.close()
def status():
print(f"DASHBOARD_APPS={ft_layout.read_conf().get('DASHBOARD_APPS', 'steamvr')}")
url = shared_context()
if not url:
print("Steam's UI: not reachable on 127.0.0.1:8080")
return 0
try:
print(f"wrapper: {steamui.evaluate(STATUS)}")
except steamui.SteamUnreachable as e:
print(f"wrapper: unknown ({e})")
return 0
def main():
if sys.argv[1:] == ["status"]:
return status()
if sys.argv[1:]:
sys.exit(__doc__)
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
url = None
try:
while True:
if wanted():
url = shared_context()
if url:
try:
session(url)
continue
except (OSError, ValueError, steamui.SteamUnreachable) as e:
log(f"Steam's UI went away ({e}); trying again")
time.sleep(HEARTBEAT)
finally:
if url: # leaving with the wrapper in: Steam's own launch at once, not 10 s from now
try:
steamui.evaluate(OFF)
except steamui.SteamUnreachable:
pass
if __name__ == "__main__":
sys.exit(main())
+68
View File
@@ -15,6 +15,9 @@ command-line side). Replies go to the sender:
launch APP.desktop [action=ACTION] [screen=N] run ["argv", ...]
(start an app, or one of its desktop file's actions, and float its first window;
with screen=N its first window opens docked on screen N instead)
dashlaunch CMDLINE (ft-dashroute: an app picked in the SteamVR dashboard's Launch a program
list, Steam's command line for it: launch it floating -> "ok", or "steamvr REASON"
when it should be SteamVR's own window after all; see dash_launch)
windows (-> JSON: the open apps' windows and where they are, for a profile; ft-layout save)
spotlist (-> JSON: every app window, its title, panel, and when it was last active; spotwatch)
activate ID (make that window KWin's active one, unminimized; spotwatch)
@@ -58,6 +61,8 @@ import json
import math
import os
import re
import shlex
import shutil
import socket
import subprocess
import sys
@@ -88,6 +93,12 @@ PLACES_PATH = os.path.expanduser("~/.config/frametop-float.json") # each app's
LAUNCH_SECONDS = 30 # a launched app's window must show up within this
IN_FRONT = 2.0 # at most this far in front of you, for an app with no remembered place
DEBUG = os.environ.get("FT_FLOAT_DEBUG") == "1" # log every event from the script
# The SteamVR dashboard's Launch a program list (float/ft-dashroute): these stay SteamVR's, since
# they aren't apps with a window for the desktop: Steam, SteamVR, the desktops themselves, and
# Frametop's layout commands (a profile's entry, Reset Screen Layout, Hide/Show Screens).
STEAMVR_ONLY = {"steam", "vrurlhandler", "steamos-nested-desktop", "frametop-session.sh", "ft-layout",
"ft-layout-reset", "ft-hide-show"}
PAUSE_PATH = f"/run/user/{os.getuid()}/frametop-pause.json" # input/game_pause.py's state
def whole(*scales):
@@ -239,6 +250,37 @@ def listable(ev):
return ev.get("app", "") not in SKIP_APPS and not any(n.startswith("ksplash") for n in (ev.get("app", ""), ev.get("cls", "")))
def exec_argv(info):
"""A desktop file's command, without its field codes (%U and the like), as Steam lists it."""
try:
argv = shlex.split(info.get_commandline() or "")
except ValueError:
return []
return [a for a in argv if not re.fullmatch(r"%[a-zA-Z]", a)]
def app_of(argv):
"""The desktop file (its name, without .desktop) whose command is argv, or None. Steam's
command line for an app is its Exec line without the field codes, each argument quoted."""
if not argv:
return None
want = [shutil.which(argv[0]) or argv[0]] + argv[1:]
for info in sorted(Gio.AppInfo.get_all(), key=lambda i: not i.should_show()): # menu entries first
got = exec_argv(info)
if got and [shutil.which(got[0]) or got[0]] + got[1:] == want:
return info.get_id().removesuffix(".desktop")
return None
def paused():
"""Frametop is paused for a VR game: its windows don't show."""
try:
with open(PAUSE_PATH) as f:
return json.load(f).get("paused") is True
except (OSError, ValueError, AttributeError):
return False
def cmdline(pid):
try:
with open(f"/proc/{int(pid)}/cmdline", "rb") as f:
@@ -879,6 +921,30 @@ class Daemon:
return self.took(waiting[0])
return None
def dash_launch(self, text):
"""An app picked in the SteamVR dashboard's Launch a program list (ft-dashroute):
launch it with its first window floating -> "ok". Or "steamvr REASON": SteamVR
opens it as its own window after all."""
if ft_layout.read_conf().get("DASHBOARD_APPS", "steamvr") != "frametop":
return "steamvr the setting is off"
try:
argv = shlex.split(text)
except ValueError:
argv = []
if not argv:
return "steamvr no command"
if os.path.basename(argv[0]) in STEAMVR_ONLY:
return f"steamvr {os.path.basename(argv[0])} is SteamVR's"
if paused():
return "steamvr Frametop is paused"
now = time.monotonic()
waiting = sum(1 for la in self.launches if la.dock is None and la.until > now)
if sum(1 for s in self.slots if s.window is None) - waiting <= 0:
return f"steamvr all {len(self.slots)} floating windows are in use"
app = app_of(argv)
reply = self.launch(app=app)[0] if app else self.launch(argv=argv)[0]
return "ok" if reply == "ok" else f"steamvr {reply}"
def took(self, la):
if not la.entries or len(la.entries) <= 1:
self.launches.remove(la)
@@ -1147,6 +1213,8 @@ class Daemon:
if not isinstance(argv, list) or not argv or not all(isinstance(a, str) for a in argv):
return "error run takes a JSON list"
return self.launch(argv=argv)[0]
if cmd == "dashlaunch" and rest:
return self.dash_launch(text.split(None, 1)[1])
if cmd in ("windows", "spotlist"):
return self.capture(sender, cmd)
if cmd == "activate" and len(rest) == 1:
+3 -2
View File
@@ -102,7 +102,7 @@ Options:
- `--sensor S`: only the mono cameras whose sensor name contains S.
- `--status S`: a status line every S seconds (0: never).
It exits when XRService exits, when a camera's buffers keep going stale, which means XRService has reallocated them, or when a camera stops while the others keep running (3 s), which means XRService has moved it to another device (the colour module was unplugged or plugged in). The service starts it again, and it attaches to the new buffers.
It exits when XRService exits, when a camera's buffers keep going stale, which means XRService has reallocated them, or when a camera stops while the others keep running (3 s), which means XRService has moved it to another device (the colour module was unplugged or plugged in). The service starts it again, and it attaches to the new buffers: after 1 s the first time, then 1.7, 2.9 and 5 s while SteamVR runs (`RestartSteps`; it can't start itself again, having dropped its capabilities). ft-hands waits for the new ring (up to 15 s) and starts itself again on its cameras, without waiting out its own service's 5 s.
**Which camera is which:** video9 is `slam_right` (sensor `og01a1b 4-0036`), video13 is `slam_left` (`4-0060`), video6 is `upper_left` and video7 is `upper_right`; without the colour module the side pair is on video0 (`slam_right`) and video3. XRService says so in its log: `Found camera 'slam_left': ... v4l_subdev=/dev/v4l-subdev30` names each sensor, and each `TrackingCameraInit` line gives the device and subdev it opened. ft-hands and `camcheck.py` name the devices that way. Until 2026-10-05 they named them by the `TrackingCameraInit` index instead, which is only the order XRService opens them in (index 0 was `slam_right` on every start logged), and ft-camd bound each buffer queue to a device by the order of XRService's file descriptors, which changes when XRService restarts its cameras. The two mistakes made the side names come out swapped on most starts and right on some. ft-camd now asks each device which buffers it holds (`VIDIOC_QUERYBUF` names the descriptor XRService queued at each index) and only falls back to the order if that fails. With the names swapped, every hand is seen by one camera only, at the wrong depth, and the hand holes land beside the hands. ft-hands still checks by itself, in case (`track/sides.h`, `HANDS_SWAP_SIDES=auto`):
- Whenever a hand's landmarks are found in two cameras at once (one of them a side camera), it intersects the rays through the 21 landmarks twice: once with the calibrations as named, once with the two side cameras exchanged. The same hand seen the right way meets within a few mm, in front of both cameras and as far away as its size says. The wrong way misses by centimetres or meets behind a camera.
@@ -134,6 +134,7 @@ Options:
- `--nice N`: niceness. Default 5, so the VR stack wins contested CPUs.
- `--no-publish`: don't write the hands and gestures files.
- Parked: while `/run/user/UID/frametop-hands/park` exists, ft-hands processes no frames (the models stay loaded), publishes no hands, and ends any gesture down, as lost; it writes `0` to `hands-want` there every second, and ft-camd stops copying the mono cameras' frames while that file is fresh (3 s) and says 0. The input relay parks it while Frametop is paused for a VR game with hand interaction on (`POINTER_HANDS=1`), instead of stopping the services, and unparks it while the dashboard is open, so the hands work there; it tracks again from a palm search. Not while recording.
- Frame sets: a set is the side pair's newer frames, taken within 3 ms, with the upper pair's from the same moment (it waits up to 8 ms after the sides for theirs). XRService keeps the uppers in step with the sides, but after a colour module hot plug they can stay out of step: on 2026-10-17 XRService logged `Set upper camera driver not in-sync` and never `in-sync` again, the uppers ran at 40 fps with no dark frames, and XRService's own tracking replaced them with dummy frames. Waiting for all four to meet left 6-7 sets a second (27-30 normally). Now, when the uppers met fewer than half of the recent sets, ft-hands says so, drops their views and tracks with the side pair alone, at the full rate; when they meet 95% again it takes them back. Taking the headset off and putting it back on restarts the camera streams, which normally brings the uppers in step within half a second (untested after a hot plug). A recording keeps only full sets.
- `--no-gestures`: hands for the cutouts only. No pinch or grip detection, so nothing reaches the pointer and a closing hand doesn't raise the rate to 30 Hz. The gestures file is removed at start. `ft-cutouts` runs it this way.
- `--record DIR`, `--record-for S`, `--record-hz N`: save every frame set for S seconds (default 120) to `DIR/sets.bin`, or at most N sets a second with `--record-hz` (the hand recorder uses 10). Every set is about 80 MB/s. Sending the tracker SIGUSR1 (`pkill -USR1 -x ft-hands`) starts a recording in `~/.local/share/frametop/hands/rec-<time>` without a restart. Recordings are images of your hands and room: they stay on the headset unless you move them.
- `--record-only`: record without tracking or publishing, so it can run beside the live tracker. Give it `--record DIR`, since SIGUSR1 would reach both trackers. With `ft-camd --with-dark`, recordings also hold each camera's newest dark frame as `<name>_dk`, which doubles the rate. With `--with-color`, each colour camera's newest frame is saved with every set, as `color_video<N>`, which adds about 70 MB/s. Run the recorder at normal I/O priority: idle I/O priority stalled a 165 MB/s recording.
@@ -228,7 +229,7 @@ ft-hands detects a pinch per hand (`track/pinch.h`) and publishes it to the gest
With `POINTER_HANDS=1`, the pointer helper (`pointer/helper/ft-pointer.cpp`) reads the gestures file every frame. It's off by default.
**The hand ray** (`POINTER_HAND_RAY=1`, the default with `POINTER_HANDS`): a bare hand raised in front and pointing, palm away, shows a ray from it, Quest-style (`include/fh_ray.h`: from a shoulder estimated from the head, through the knuckles), and the ray drives the pointer's virtual controller, so it works on SteamVR's dashboard, Steam's pages, overlays and Frametop's panels like a controller's laser. The ray comes up as the thumb nears the index or middle tip (`POINTER_RAY_READY`). With the ray up, a pinch clicks (a held pinch drags), a middle pinch right-clicks (held and moved, it scrolls), a three-finger pinch (thumb, index and middle together) scrolls as the hand moves, and while a pinch carries a panel, reaching out pushes it away and pulling in brings it near. With the hand low or the palm toward you, there's no ray, and pinches and grips work as below. In gaze mode there's no ray at all unless `POINTER_GAZE_HAND_RAY=1`. A hand holding a controller, or whose controller was used in the last seconds, has no ray and no gestures (the helper's handoff, `pointer/helper/handoff.h`). The rules, timings and measurements are at the top of `ft-pointer.cpp` ("Handoff", "Hand ray") and in frame-hands' `notes/hand-interaction.md`. `pointer/tools/rayeval` runs the ray over a replay (`ft-handreplay --ray`).
**The hand ray** (`POINTER_HAND_RAY=1`, the default with `POINTER_HANDS`): a bare hand in front and pointing, palm away, high or low (not while it types: the typing guard; `POINTER_RAY_RAISED=30` brings back the old limit, the knuckles within 30 cm under the eyes), shows a ray from it, Quest-style (`include/fh_ray.h`: from a shoulder estimated from the head, through the knuckles), and the ray drives the pointer's virtual controller, so it works on SteamVR's dashboard, Steam's pages, overlays and Frametop's panels like a controller's laser. The ray comes up as the thumb nears the index or middle tip (`POINTER_RAY_READY`). With the ray up, a pinch clicks (a held pinch drags), a middle pinch right-clicks (held and moved, it scrolls), a three-finger pinch (thumb, index and middle together) scrolls as the hand moves, and while a pinch carries a panel, reaching out pushes it away and pulling in brings it near. With the hand low or the palm toward you, there's no ray, and pinches and grips work as below. In gaze mode there's no ray at all unless `POINTER_GAZE_HAND_RAY=1`. A hand holding a controller, or whose controller was used in the last seconds, has no ray and no gestures (the helper's handoff, `pointer/helper/handoff.h`). The rules, timings and measurements are at the top of `ft-pointer.cpp` ("Handoff", "Hand ray") and in frame-hands' `notes/hand-interaction.md`. `pointer/tools/rayeval` runs the ray over a replay (`ft-handreplay --ray`).
- **Pinch to click, in gaze mode.** Your eyes aim and your hands click, Vision Pro style, and the hand ray stays off (`POINTER_GAZE_HAND_RAY`). A pinch locks the pointer where you were looking just before the fingers began to close (75 ms before ft-hands saw the pinch, since the eyes often move on as the hand closes), and a quick tap clicks there. Pinch, hold and move the hand to nudge the pointer: nothing moves until the hand has moved 4 degrees (about 3 cm at arm's length), then the pointer follows at 0.3 times the hand's movement. Letting go clicks where the pointer is, and the correction is a lesson for the gaze tracker, as with the mouse. Held still for `POINTER_GAZE_HOLD` (0.5 s) instead, the pinch is a real press at that point, and the hand drags at `POINTER_GRIP_GAIN`. A middle pinch is a right click the same way. Hold a pinch with one hand and pinch with the other: both buttons are down, left for the first hand and right for the second, each until its own pinch opens (a first pinch not yet pressed becomes a press at once, where the pointer is), as when you hold both of a mouse's buttons. The rules and timings are in `pointer/helper/gazehand.h`.
- **Without gaze mode,** a pinch is a real press, like the mouse's button: pressed when it closes, released when it opens, and while it's held the hand drags the pointer. A tap is still a click where the pointer is.
+9 -1
View File
@@ -5,6 +5,9 @@ Documentation=file://@REPO@/hands/README.md
# It borrows XRService's camera buffers, so it comes and goes with SteamVR.
After=steamvr.service
PartOf=steamvr.service
# Room for the quick restarts below, while XRService is away (8 starts in 30 s at most).
StartLimitIntervalSec=30
StartLimitBurst=12
[Service]
# On the host: the dev container can't reach XRService. Its file capabilities (set by
@@ -15,7 +18,12 @@ PartOf=steamvr.service
# Add --with-color to try the colour cameras (ft-hands then picks them by the light).
ExecStart=@REPO@/hands/build/ft-camd --status 60
Restart=always
RestartSec=5
# 1 s at first, so hand tracking is back soon after a colour module hot plug (it can't restart
# itself: it has dropped its capabilities), then longer: 1.7, 2.9, 5 s (the count only resets
# when SteamVR starts it).
RestartSec=1
RestartSteps=3
RestartMaxDelaySec=5
TimeoutStopSec=5
[Install]
+6 -2
View File
@@ -58,7 +58,11 @@
// tracked a two-camera (STEREO) frame within a frame within lostFor (250 ms); one-camera
// stereoWithin (200 ms) depth is 2-6 cm off, fine for a short gap
// raised K at most raisedShow (0.30 m) below raisedHide (0.40 m): gaze pinches sat
// the eyes 0.35-0.45 m below the eyes, elbow resting
// the eyes (0: off; the pointer helper 0.35-0.45 m below the eyes, elbow resting
// turns it off: user, 2026-10-19, "hands
// still recognized even if they are low,
// just not if they are typing", which its
// typing guard holds back)
// forward K at least forwardShow (0.25 m) ahead forwardHide (0.20 m)
// of the eyes, along the torso's yaw
// palm away palm normal . D >= palmShow (0.3) palmHide (0.0): palm toward you is reserved
@@ -324,7 +328,7 @@ inline Tests Evaluate(const Params &p, const Body &body, const HandFrame &f, con
if (!s) r.fails += r.fails.empty() ? name : std::string(",") + name;
show = show && s, keep = keep && k;
};
test(r.below <= p.raisedShow, r.below <= p.raisedHide, "raised");
if (p.raisedShow > 0) test(r.below <= p.raisedShow, r.below <= std::max(p.raisedShow, p.raisedHide), "raised");
test(r.ahead >= p.forwardShow, r.ahead >= p.forwardHide, "forward");
test(r.palm >= p.palmShow, r.palm >= p.palmHide, "palm");
if (r.forearmUsed) test(r.forearm >= p.forearmShow, r.forearm >= p.forearmHide, "forearm");
+25 -2
View File
@@ -1,7 +1,7 @@
{
"catalog": 1,
"kind": "hands",
"latest": "v16b",
"latest": "v19b",
"default": "latest",
"baseline": "stock",
"metrics": [
@@ -19,11 +19,34 @@
"note": "Of the held-out views where the thumb and index tips touch, the share the model reads as touching."}
],
"models": [
{
"name": "v19b",
"title": "Frametop v19b",
"kind": "custom",
"status": "recommended",
"date": "2026-10-19",
"summary": "Taught by the bounty rounds' prompts: held pinches and middle-finger touches read closed far more often.",
"notes": "v13b-p1's landmark model trained further on v16b's data plus Frametop Hands contributions #15 (pokes), #16 (middle-finger taps) and #17 (card grabs). New: where a bounty round's prompt asked for a fingertip touch, training pulls those tips together, and where it asked for a resting hand, it keeps them apart. The palm detector is v13b-p1's, unchanged. Same networks as stock, so the same CPU cost.\n\nWhat to expect against v16b: on prompted acts held back from training, a held thumb-index pinch reads closed 58% of the time (v16b 21%) and a held thumb-middle touch 100% (v16b 33%). The replays track the same hands, and finger points and hand shape are as close as before. The held-out pinch number below is lower (69% against 80%) because it counts touches by the teacher model's labels, which miss many real touches; v16b followed them more closely.\n\nKnown weak spots: it reads the thumb and index tips a few millimetres further apart than v16b, so a light pinch may need a firmer squeeze, and a resting hand reads as touching a little more often for some people.",
"cost": "Same as stock: two networks, fp16, on the cores hand tracking already uses.",
"bench": {"replay_bright": 961, "replay_dim": 1225, "replay_lit": 1434, "found": 98.6, "landmark_px": 3.8,
"within5": 94.9, "shape_mm": 8.0, "side_ok": 96.7, "pinch_closed": 69.0},
"format": 1,
"rev": "52d3a657abcf0cef7db48f90c5fefb281ff4b5b7",
"files": {
"NOTICE": "96fc8914e0220e51222362d141e0495e3425081d9898c21899b63e68df358ca6",
"hand.ncnn.bin": "010f96a9316d0d935f2baf4951770130402bddc126b0066b4d8b3ba2754fdc9a",
"hand.ncnn.param": "54e08f284ff6f121ff00f4a9c65afc38c0dbb22a32f4af44fc4e0fde6c4a1bbd",
"palm.ncnn.bin": "9865f5fa3c6bc885cb0b50f201c4ac1bd83b2c1ad64af4e122efa3fc56d61909",
"palm.ncnn.param": "c61df2dde5b221a55c35c9693e530845a95ce2732949a9b3d3c583e3b2bae92e",
"LICENSE": "41003d4a74749c0220e33dd415042164b5a1093ed401f36277234f772d22d3d0",
"README.md": "3393752d047bc3dd4e23fca47e3c29e4ae37c13be8dae1dae3412be55f4085f0"
}
},
{
"name": "v16b",
"title": "Frametop v16b",
"kind": "custom",
"status": "recommended",
"status": "older",
"date": "2026-10-16",
"summary": "v13b-p1 plus the pinch and tap bounty rounds: pinches and taps read closed more often.",
"notes": "v13b-p1's landmark model trained further with Frametop Hands contributions #12 (index taps), #13 (pinches) and #14. The palm detector is v13b-p1's, unchanged. Same networks as stock, so the same CPU cost.\n\nWhat to expect against v13b-p1: a thumb touching the index finger is read as touching more often (80% of held-out touches against 71%), so pinches and taps register more reliably. Finger points and hand shape are as close as before, and the replays track the same hands.\n\nKnown weak spots: a thumb touching the middle finger is still often read as a near miss, and empty views are taken for a hand slightly more often than with v13b-p1 (0.37% against 0.22% of empty crops).",
+5 -5
View File
@@ -5,12 +5,12 @@
# needs to run them: raise it when a model needs a newer ft-hands, and older installs keep theirs.
# Made by frame-hands' model-repo/pin.py.
format 1
rev d96e56599b0af18fc05af2a0a4489985914b754a
model v16b
file NOTICE 0d31ad8d108fc01e9fc4c5612eb5a9b16d90f9a971e6677cafe4969c2388fc06
file hand.ncnn.bin c602ad2344bd1676a628fc8ab4fca2f53574b9f12bc97c705f47762d23f5f941
rev 52d3a657abcf0cef7db48f90c5fefb281ff4b5b7
model v19b
file NOTICE 96fc8914e0220e51222362d141e0495e3425081d9898c21899b63e68df358ca6
file hand.ncnn.bin 010f96a9316d0d935f2baf4951770130402bddc126b0066b4d8b3ba2754fdc9a
file hand.ncnn.param 54e08f284ff6f121ff00f4a9c65afc38c0dbb22a32f4af44fc4e0fde6c4a1bbd
file palm.ncnn.bin 9865f5fa3c6bc885cb0b50f201c4ac1bd83b2c1ad64af4e122efa3fc56d61909
file palm.ncnn.param c61df2dde5b221a55c35c9693e530845a95ce2732949a9b3d3c583e3b2bae92e
file LICENSE 41003d4a74749c0220e33dd415042164b5a1093ed401f36277234f772d22d3d0
file README.md 322fc0b146d301865592958ba4dd512bf249836816b578f0919a2f671f3f014c
file README.md 3393752d047bc3dd4e23fca47e3c29e4ae37c13be8dae1dae3412be55f4085f0
+30
View File
@@ -153,6 +153,36 @@ int main() {
CHECK(std::fabs(left.len[5] - got.len[5]) < 1e-9);
}
// a left hand stuck fitted as a right one (the mirror won while its fingers were straight)
// goes back to the model's side once its fingers curl a little: the mirror follows them only
// by bending its own backward, and at 20 degrees it fits well enough to stay (2026-10-19,
// before the guard: right in every frame, knuckles at -28 degrees)
{
Skeleton sk;
const double D = M_PI / 180;
const Shape truth = Skeleton::canonical(1.0);
Rot R0 = exp_rot({0.3, 0.2, 0.1}), fr[21];
const V3 t0{-0.12, -0.2, -0.38};
double q0[kParams] = {};
for (int c = 0; c < 4; ++c) q0[6 + 5 * c + 1] = 20 * D, q0[6 + 5 * c + 3] = 20 * D, q0[6 + 5 * c + 4] = 10 * D;
V3 pts[21];
Skeleton::pose(truth, -1, R0, t0, q0, pts, fr);
Fit f;
f.valid = true, f.side = 1, f.flipped = true, f.ns = 1'000'000'000;
int64_t ns = f.ns;
int asRight = 0;
for (int i = 0; i < 10; ++i) {
ns += 33'000'000;
sk.update(f, pts, false, 0.1, 2, 0.0015, nullptr, {0, 0, 0}, 1.0, ns);
asRight += f.side == 1;
}
double mcp = 0;
for (int c = 0; c < 4; ++c) mcp += f.qf[6 + 5 * c + 1] / 4;
std::printf("flipped left hand, curled: %d of 10 frames as right, mean knuckle bend %.0f deg\n", asRight, mcp / D);
CHECK(asRight == 0 && f.valid && !f.flipped);
CHECK(mcp > 15 * D);
}
// the elbow: behind and below a hand held in front, 2.8 palms away
{
const V3 wrist{0.15, -0.25, -0.35}, knuckle{0.15, -0.22, -0.43};
+120 -20
View File
@@ -599,6 +599,63 @@ int main(int argc, char **argv) {
tracker.set_hold(reacquire > 0 ? hold : 0.3);
tracker.set_skeleton(skeleton);
std::map<std::string, std::vector<uint8_t>> pixels;
// A mono set is the side pair's newer frames (all the mono cameras' without both sides) with
// the upper pair's from the same moment. XRService keeps the uppers in step with the sides,
// but after a colour module hot plug they may stay out of step ("Set upper camera driver not
// in-sync", 2026-10-17: 40 fps against the sides' 30, no dark frames, for good). Waiting for
// all four to meet then left 6-7 sets a second; out of step, sets hold the side pair alone
// (until the uppers keep meeting them again).
std::vector<std::string> lead, follow;
for (auto &[name, i] : index)
(!have_sides || name == "slam_left" || name == "slam_right" ? lead : follow).push_back(name);
bool follow_in_step = true;
double follow_rate = 1; // how often the uppers met a set lately (a moving average)
uint64_t prev_t0 = 0, prev_t1 = 0;
// a camera's frame from within 3 ms of the set's frames (taken t0 to t1), newer than after, or 0
auto follow_frame = [&](int i, uint64_t t0, uint64_t t1, uint64_t after) -> uint64_t {
uint64_t n = ring.latest(i);
for (int k = 0; k < 4 && n > after; ++k, --n) {
fh_ring_slot_t m;
if (!ring.meta(i, n, &m)) continue;
if (m.capture_ns + 3'000'000 < t1) break; // older than the set: earlier ones are too
if (m.capture_ns <= t0 + 3'000'000) return n;
}
return 0;
};
// Did camera i take a frame within 3 ms of t0 to t1? 1 yes, 0 no, -1 can't tell: the ring
// (4 frames) no longer reaches back that far, as when a step took long.
auto met_set = [&](int i, uint64_t t0, uint64_t t1) -> int {
const uint64_t latest = ring.latest(i);
for (uint64_t n = latest; n > 0 && latest - n < 4; --n) {
fh_ring_slot_t m;
if (!ring.meta(i, n, &m)) return -1;
if (m.capture_ns + 3'000'000 < t1) return 0;
if (m.capture_ns <= t0 + 3'000'000) return 1;
}
return -1;
};
// A set used or passed by: did the uppers meet the one before it? (theirs have come by now)
auto note_step = [&](uint64_t t0, uint64_t t1) {
int met = 1;
if (!follow.empty() && prev_t1)
for (const std::string &name : follow) met = std::min(met, met_set(index[name], prev_t0, prev_t1));
if (!follow.empty() && prev_t1 && met >= 0) {
follow_rate += 0.1 * (met - follow_rate);
if (follow_in_step && follow_rate < 0.5) {
follow_in_step = false;
for (const std::string &name : follow) tracker.drop_camera(name);
std::printf("upper cameras out of step with the side ones (XRService's \"upper camera driver not in-sync\", "
"after a colour module hot plug): tracking with the side pair alone. Taking the headset off "
"and putting it back on may bring them back.\n");
std::fflush(stdout);
} else if (!follow_in_step && follow_rate > 0.95) {
follow_in_step = true;
std::printf("upper cameras in step with the side ones again: tracking with all four\n");
std::fflush(stdout);
}
}
prev_t0 = t0, prev_t1 = t1;
};
// The flag buffer (see the top), and its request file.
std::unique_ptr<FlagBuffer> flags;
const std::string flag_file = run_dir() + "/flag";
@@ -894,37 +951,53 @@ int main(int argc, char **argv) {
uint64_t tmin = UINT64_MAX, dq = 0;
if (mono_driven) {
// a new frame set: every mono camera has a newer frame, taken at the same moment
// a new frame set: the side pair has newer frames, taken at the same moment (see lead)
std::map<std::string, uint64_t> latest;
bool ready = true;
for (auto &[name, i] : index) {
latest[name] = ring.latest(i);
for (const std::string &name : lead) {
latest[name] = ring.latest(index[name]);
ready = ready && latest[name] > last[name];
}
if (!ready) {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
continue;
}
// not needed at the current rate, and not recorded: skip it without copying images
if (track && !rec && !g_record) {
uint64_t t0 = UINT64_MAX, t1 = 0;
bool ok = true;
for (auto &[name, i] : index) {
fh_ring_slot_t meta;
ok = ok && ring.meta(i, latest[name], &meta);
if (ok) t0 = std::min(t0, meta.capture_ns), t1 = std::max(t1, meta.capture_ns);
}
if (ok && t1 - t0 <= 3'000'000 && t0 < next_ns) {
for (auto &[name, i] : index) last[name] = latest[name];
continue;
}
uint64_t t0 = UINT64_MAX, t1 = 0, lead_dq = 0;
bool met = true;
for (const std::string &name : lead) {
fh_ring_slot_t meta;
met = met && ring.meta(index[name], latest[name], &meta);
if (met) t0 = std::min(t0, meta.capture_ns), t1 = std::max(t1, meta.capture_ns), lead_dq = std::max(lead_dq, meta.dqbuf_ns);
}
if (!met || t1 - t0 > 3'000'000) { // one is a frame ahead: wait for the other
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
// the uppers' frames from the same moment: in step, theirs may be a few ms behind
std::map<std::string, uint64_t> with;
if (follow_in_step)
for (const std::string &name : follow)
if (const uint64_t n = follow_frame(index[name], t0, t1, last[name])) with[name] = n;
if (follow_in_step && with.size() < follow.size() && mono_ns() < lead_dq + 8'000'000) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
const bool full = with.size() == follow.size();
// not needed at the current rate, and not recorded: skip it without copying images
if (track && !rec && !g_record && t0 < next_ns) {
note_step(t0, t1);
for (const std::string &name : lead) last[name] = latest[name];
for (auto &[name, n] : with) last[name] = n;
continue;
}
for (auto &[name, n] : with) latest[name] = n;
std::vector<SetFrame> frames;
uint64_t tmax = 0;
bool ok = true;
for (auto &[name, i] : index) {
for (auto &[name, n] : latest) {
const int i = index[name];
fh_ring_slot_t meta;
ok = ok && ring.read(i, latest[name], pixels[name], &meta);
ok = ok && ring.read(i, n, pixels[name], &meta);
if (!ok) break;
const auto &c = ring.camera(i);
images[name] = {pixels[name].data(), int(c.width), int(c.height), int(c.width)};
@@ -937,7 +1010,8 @@ int main(int argc, char **argv) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
continue;
}
for (auto &[name, i] : index) last[name] = latest[name];
note_step(t0, t1);
for (auto &[name, n] : latest) last[name] = n;
if (g_record && !rec) {
g_record = 0;
char name[64];
@@ -957,7 +1031,7 @@ int main(int argc, char **argv) {
std::printf("recording done: %zu sets, %zu dropped\n", n, d);
std::fflush(stdout);
if (!track) break;
} else if (due) {
} else if (due && full) { // a set without the uppers isn't recorded
for (auto &[name, i] : dark) { // the newest dark and color frames, as they are
fh_ring_slot_t meta;
const uint64_t n = ring.latest(i);
@@ -1090,6 +1164,9 @@ int main(int argc, char **argv) {
s.handoff_miss, s.dups, s.splits, s.created, s.merged, s.forgotten,
!rec ? "" : (" recorded " + std::to_string(rec->written()) + " dropped " +
std::to_string(rec->dropped())).c_str());
if (!follow.empty() && !follow_in_step)
std::printf(" upper cameras out of step with the side ones (met %.0f%% of sets lately): side pair alone\n",
100 * follow_rate);
if (flags) {
const FlagBuffer::Stats fs = flags->stats();
std::printf(" flag buffer: %zu sets, %zu crops (%zu ghosts) in %.1f MB, %.0f us a crop, %zu dropped, %zu flags written%s%s\n",
@@ -1136,5 +1213,28 @@ int main(int argc, char **argv) {
grip.release(int64_t(mono_ns()));
gestures.write(pinch, grip, mono_ns(), &middle);
}
// ft-camd stopped: a new one comes when XRService moves the cameras (a colour module hot plug)
// or restarts. Wait for it here and start again on its cameras, rather than exit and wait out
// the unit's RestartSec as well. Its ring is a new file (camd.c unlinks the old one).
if (exit_code == 2 && publish && track && !g_stop) {
rec.reset(); // writes out what's queued
Ring next;
ino_t next_ino = ring_st.st_ino;
for (const uint64_t t = mono_ns(); !g_stop && mono_ns() - t < 15'000'000'000ull;
std::this_thread::sleep_for(std::chrono::milliseconds(100))) {
struct stat st;
std::string e;
if (next_ino == ring_st.st_ino && stat(ring_path.c_str(), &st) == 0 && st.st_ino != ring_st.st_ino &&
next.open(ring_path.c_str(), e))
next_ino = st.st_ino;
if (next_ino != ring_st.st_ino && next.alive()) {
std::printf("ft-camd is back: starting again on its cameras\n");
std::fflush(stdout);
execv("/proc/self/exe", argv);
std::perror("execv /proc/self/exe");
break;
}
}
}
return exit_code;
}
+13 -2
View File
@@ -3,11 +3,15 @@
// run after run. For A/B tests of how the tracker runs.
//
// ft-ringplay DIR --ring PATH [--from S] [--to S] [--loop] [--cpus 0,1]
// [--upper-skew MS] [--upper-drift MS_PER_S]
//
// Frames are stamped as they're published, so the tracker's latency figures stay
// meaningful. Cameras carry their calibration name and no device node (ft-hands maps
// them by name), so a recording made with the right names needs no --swap-sides.
// Dark frames (<name>_dk) are skipped. Needs no root: the ring is an ordinary file.
// --upper-skew and --upper-drift stamp the upper cameras' frames MS (plus MS_PER_S a second,
// modulo a 30 fps frame) later than the sides', as after a colour module hot plug that left
// them out of step (hands/README.md, "Frame sets").
#include "record.h"
extern "C" {
@@ -20,6 +24,7 @@ extern "C" {
#include <unistd.h>
#include <algorithm>
#include <cmath>
#include <cerrno>
#include <csignal>
#include <cstdio>
@@ -87,12 +92,13 @@ bool is_dark(const char *name) {
int main(int argc, char **argv) {
if (argc < 2 || argv[1][0] == '-') {
std::fprintf(stderr, "usage: %s DIR --ring PATH [--from S] [--to S] [--loop] [--cpus 0,1]\n", argv[0]);
std::fprintf(stderr, "usage: %s DIR --ring PATH [--from S] [--to S] [--loop] [--cpus 0,1] [--upper-skew MS] "
"[--upper-drift MS_PER_S]\n", argv[0]);
return 1;
}
const std::string dir = argv[1];
std::string ring_path;
double from = 0, to = 1e9;
double from = 0, to = 1e9, upper_skew_ms = 0, upper_drift_ms = 0;
bool loop = false;
std::vector<int> cpus = {0, 1};
for (int i = 2; i < argc; ++i) {
@@ -102,6 +108,8 @@ int main(int argc, char **argv) {
else if (a == "--from" && more) from = std::atof(argv[++i]);
else if (a == "--to" && more) to = std::atof(argv[++i]);
else if (a == "--loop") loop = true;
else if (a == "--upper-skew" && more) upper_skew_ms = std::atof(argv[++i]);
else if (a == "--upper-drift" && more) upper_drift_ms = std::atof(argv[++i]);
else if (a == "--cpus" && more) {
cpus.clear();
for (char *p = argv[++i]; *p;) {
@@ -185,6 +193,7 @@ int main(int argc, char **argv) {
std::fflush(stdout);
uint64_t published = 0, rounds = 0;
const uint64_t t_play = clock_ns(CLOCK_MONOTONIC);
for (;;) {
// one pass over [from, to]: each set goes out at its recorded offset from the first
while (have && set_time(cams) - rec0 < from && !g_stop) {
@@ -202,6 +211,7 @@ int main(int argc, char **argv) {
nanosleep(&ts, nullptr);
}
const uint64_t raw = clock_ns(CLOCK_MONOTONIC_RAW), mono = clock_ns(CLOCK_MONOTONIC);
const double skew_ms = std::fmod(upper_skew_ms + upper_drift_ms * (mono - t_play) * 1e-9, 1000.0 / 30);
for (size_t r = 0; r < pub.size(); ++r) {
fh_ring_cam_t &rc = hdr->cams[r];
if (px[pub[r]].size() != size_t(rc.width) * rc.height) continue;
@@ -212,6 +222,7 @@ int main(int argc, char **argv) {
std::memcpy(reinterpret_cast<uint8_t *>(s + 1), px[pub[r]].data(), px[pub[r]].size());
s->frame = n;
s->capture_ns = raw; // taken now, as a live camera's frame would be
if (!std::strncmp(rc.name, "upper", 5)) s->capture_ns += uint64_t(std::max(0.0, skew_ms) * 1e6);
s->dqbuf_ns = mono;
s->publish_ns = mono;
__atomic_store_n(&s->seq, 2 * n + 2, __ATOMIC_RELEASE);
+24 -1
View File
@@ -620,6 +620,29 @@ void Skeleton::update(Fit &f, const V3 pts[21], bool right, double right_score,
double crms[5] = {};
double cost = solve(s, side, pts, sl, sd, eye, prev, p_.iterations, R, t, q, &rms, p_.limits, crms, p_.warm > 0);
if (cost < 0) return;
// A mirror image follows fingers that bend toward the palm only by bending its own backward
// (the knuckles allow -35 degrees): a fit as the other side than the model's call that does,
// on average over the four fingers, is the wrong side however well it fits. Such a fit goes
// back to the model's side at once, and a mirror that needs it never wins.
auto backward = [](const double *qq) {
double m = 0;
for (int c = 0; c < 4; ++c) m += qq[6 + 5 * c + 1];
return m / 4 < -10 * D;
};
if (side != model_side && backward(q)) {
Rot R2;
V3 t2;
double q2[kParams] = {}, rms2 = 0, crms2[5] = {};
const Shape s2 = shape(model_side, scale);
const double cost2 = solve(s2, model_side, pts, sl, sd, eye, nullptr, p_.iterations, R2, t2, q2, &rms2, p_.limits,
crms2, p_.warm > 0);
if (cost2 >= 0) {
side = model_side, s = s2, R = R2, t = t2, rms = rms2, cost = cost2;
std::copy(q2, q2 + kParams, q);
std::copy(crms2, crms2 + 5, crms);
f.votes = 0;
}
}
bool better = false;
if (rms > 0.015) {
Rot R2;
@@ -629,7 +652,7 @@ void Skeleton::update(Fit &f, const V3 pts[21], bool right, double right_score,
double crms2[5] = {};
const double cost2 = solve(s2, -side, pts, sl, sd, eye, continuing && f.side == -side ? f.q : nullptr,
p_.iterations, R2, t2, q2, &rms2, p_.limits, crms2, p_.warm > 0);
better = cost2 >= 0 && cost2 < 0.5 * cost;
better = cost2 >= 0 && cost2 < 0.5 * cost && !(-side != model_side && backward(q2));
if (better && ++f.votes >= kFlipFrames) {
side = -side, s = s2, R = R2, t = t2, rms = rms2, cost = cost2;
std::copy(q2, q2 + kParams, q);
+126 -11
View File
@@ -4,7 +4,8 @@
A Kirigami (QML) app with a Python backend. It runs in the dev container and talks
to the input relay over its control socket (@frametop_relay):
- Devices: every USB/Bluetooth mouse and keyboard, a live activity light to
identify them, and a role for each (3D pointer, pass through, ignore).
identify them, and a role for each (3D pointer, pass through, keyboard + 3D pointer for a
keyboard with a touchpad, ignore).
- Buttons: press a button or key on a pointer device, then pick an action.
- Controllers: the same for the Frame controllers' buttons, minus the gaze actions (gaze
mode is a mouse feature). They're read by the pointer helper through SteamVR input
@@ -54,7 +55,7 @@ import sys
import threading
import time
from PySide6.QtCore import Property, QObject, QSocketNotifier, QTimer, QUrl, Signal, Slot
from PySide6.QtCore import Property, QObject, QProcess, QSocketNotifier, QTimer, QUrl, Signal, Slot
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
@@ -166,6 +167,9 @@ HAND_INTERACTIONS = [
("POINTER_RAY_READY", True, "6", "Laser", "Laser only when about to pinch",
"A hand's laser shows once its thumb and index are within 6 cm, and goes when they open past 10 cm. "
"Off: whenever the hand is raised and pointing."),
("POINTER_RAY_RAISED", False, "30", "Laser", "No laser for a low hand",
"A hand's laser shows only with the knuckles within 30 cm under your eyes, and goes past 40 cm. "
"Off: a low hand points too. Hands typing on a keyboard never do."),
("POINTER_RAY_FIST_CURL", True, "1.1", "Laser", "No laser or clicks in a fist",
"Closing the hand drops its laser, and a pinch made while the hand closes into a fist doesn't click."),
("POINTER_PINCH_NO_RAY", False, "1", "Laser", "Pinch with no laser clicks at the pointer",
@@ -219,7 +223,10 @@ VR_KEYBOARD_MODES = {
"button": "Only with a mapped mouse or controller button",
"never": "Never",
}
ROLE_LABELS = {"pointer": "3D pointer", "passthrough": "Pass through", "ignore": "Ignore"}
# "combo" splits a device by node (the relay's node_role): a keyboard's touchpad is the pointer, its
# keys pass through. The Devices page offers it only for devices with a mouse and a keyboard node.
ROLE_LABELS = {"pointer": "3D pointer", "passthrough": "Pass through", "combo": "Keyboard + 3D pointer",
"ignore": "Ignore"}
# Frame controller buttons the pointer helper can read (pointer/helper/vrbuttons.h). The
# system button stays SteamVR's.
CONTROLLER_BUTTONS = {
@@ -410,7 +417,7 @@ def write_conf_value(key, value):
f.write("\n".join(lines) + "\n")
def host(*cmd):
def host(*cmd, timeout=20):
"""Run a command on the SteamOS host (we live in the dev container), on the host's user
bus. The app itself has the bus of the session it was opened from (ft-input-settings),
in the Frametop desktop a private one from dbus-run-session; distrobox-host-exec run on
@@ -419,7 +426,7 @@ def host(*cmd):
runner = ["distrobox-host-exec"] if shutil.which("distrobox-host-exec") else []
env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{os.getuid()}/bus")
try:
return subprocess.run(runner + list(cmd), capture_output=True, text=True, timeout=20, env=env)
return subprocess.run(runner + list(cmd), capture_output=True, text=True, timeout=timeout, env=env)
except (OSError, subprocess.TimeoutExpired) as e:
return subprocess.CompletedProcess(cmd, 1, "", str(e))
@@ -481,6 +488,7 @@ class Backend(QObject):
self._combo_tap_code = None # that modifier's key as it is (Right Ctrl: 97)
self._voice = {} # ft-voiced's status, {} when it isn't running
self._voice_enabled = False # its service starts at login (systemctl, refreshVoice)
self._model_fetching = "" # the whisper model voice/models.sh is downloading
self._vr = {} # the helper's vrstatus, {} when it doesn't answer
self._vr_at = 0.0
self._gaze = {} # ft-gazed's status, {} when it isn't running
@@ -631,8 +639,10 @@ class Backend(QObject):
self._combo_tap = None # a mouse button: no tap
elif t == "event":
self.activity.emit(msg["id"])
# Not a Keyboard + 3D pointer device's keys: its keyboard node passes through, so
# its button map never sees them.
if (self._capture_id and msg["id"] == self._capture_id and msg["type"] == "key"
and msg["value"] == 1):
and msg["value"] == 1 and msg.get("role") != "passthrough"):
code = int(msg["code"])
self._capture_id = ""
self.captured.emit(code, self.codeName(code))
@@ -672,6 +682,8 @@ class Backend(QObject):
d["name"] = n["name"].replace(" Mouse", "") # the Z3's nodes are "Z3 Mouse"/"Z3 Keyboard"
for d in grouped.values():
d["connected"] = True
if rules.get(d["id"], {}).get("role") == "combo":
d["role"] = "combo" # its nodes say pointer and passthrough
for device_id in set(rules) | {i for i, b in bindings.items() if b}:
if device_id in grouped:
continue
@@ -1204,18 +1216,21 @@ class Backend(QObject):
self.message.emit("Gaze dot: " + ("always shown" if on else "shown only while the mouse moves it"), False)
# --- key combinations ("key_bindings") ---
def comboName(self, combo):
def comboName(self, combo, action=None):
"""A combination's name. A lone modifier is a tap, or held with dictate_hold (the relay
starts dictation on its press and stops it on the release)."""
parts = [int(c) for c in combo.split("+") if c.isdigit()]
tap = "" if action == "dictate_hold" else " tap"
if len(parts) == 1 and parts[0] in MODIFIER_NAMES:
return MODIFIER_NAMES[parts[0]] + " tap"
return MODIFIER_NAMES[parts[0]] + tap
if len(parts) == 1 and parts[0] in RIGHT_MODIFIER_NAMES:
return RIGHT_MODIFIER_NAMES[parts[0]] + " tap"
return RIGHT_MODIFIER_NAMES[parts[0]] + tap
return "+".join(MODIFIER_NAMES.get(c) or self.codeName(c).removeprefix("KEY_").title() for c in parts)
@Property("QVariantList", notify=mappingsChanged)
def keyShortcuts(self):
bound = key_bindings(read_json(RULES_PATH))
return [{"combo": c, "label": self.comboName(c), "action": a, "actionLabel": action_label(a)}
return [{"combo": c, "label": self.comboName(c, a), "action": a, "actionLabel": action_label(a)}
for c, a in sorted(bound.items())]
@Property("QVariantList", constant=True)
@@ -1361,9 +1376,102 @@ class Backend(QObject):
@Property("QVariantList", notify=mappingsChanged)
def dictationKeys(self):
return [{"combo": c, "label": self.comboName(c), "actionLabel": action_label(a)}
return [{"combo": c, "label": self.comboName(c, a), "actionLabel": action_label(a)}
for c, a in sorted(key_bindings(read_json(RULES_PATH)).items()) if a in VOICE_ACTIONS]
@Property("QVariantList", constant=True)
def dictationKeyModes(self):
return [{"value": "dictate", "text": "Tap to start, tap again to stop"},
{"value": "dictate_hold", "text": "Hold to talk"}]
@Property(str, notify=mappingsChanged)
def dictationKeyMode(self):
"""dictate or dictate_hold when all of dictation's keys do the same, else ""."""
modes = {a for a in key_bindings(read_json(RULES_PATH)).values() if a in VOICE_ACTIONS}
return modes.pop() if len(modes) == 1 else ""
@Slot(str)
def setDictationKeyMode(self, action):
"""Every dictation key toggles (dictate) or is held to talk (dictate_hold)."""
if action not in VOICE_ACTIONS:
return
rules = read_json(RULES_PATH)
bound = key_bindings(rules)
if not any(a in VOICE_ACTIONS for a in bound.values()):
self.message.emit("Dictation has no keys: add one on the Keyboard page", True)
return
rules["key_bindings"] = {c: action if a in VOICE_ACTIONS else a for c, a in bound.items()}
self._save_rules(rules)
self.message.emit("Dictation keys: " + ("hold to talk" if action == "dictate_hold"
else "tap to start, tap again to stop"), False)
@Property("QVariantList", constant=True)
def voiceLanguages(self):
return [{"value": k, "text": v} for k, v in voice_settings.LANGUAGES.items()]
@Slot(str)
def setVoiceLanguage(self, language):
if language in voice_settings.LANGUAGES:
s = voice_settings.load()
s["language"] = language
want = voice_settings.model_for(s)
self._set_voice("language", language, "Dictation language: " + voice_settings.LANGUAGES[language]
+ ("" if want == s["model"] else f" (with whisper {want})"))
if not voice_settings.model_installed(want):
self._fetch_model(want)
@Property("QVariantList", notify=voiceChanged)
def voiceModels(self):
"""Whisper models for the Voice page: English-only ones only while the language is English."""
english = voice_settings.load()["language"] == "en"
return [{"value": name, "installed": voice_settings.model_installed(name),
"text": f"{name} ({mb} MB{', English only' if langs == 'en' else ''}"
f"{'' if voice_settings.model_installed(name) else ', downloads when chosen'})"}
for name, (_, mb, langs) in voice_settings.WHISPER_MODELS.items() if english or langs != "en"]
@Property(str, notify=voiceChanged)
def voiceModel(self):
"""The model ft-voiced uses (voice_settings.model_for)."""
return voice_settings.model_for(voice_settings.load())
@Property(str, notify=voiceChanged)
def voiceModelFetching(self):
return self._model_fetching
@Slot(str)
def setVoiceModel(self, name):
if name not in voice_settings.WHISPER_MODELS or self._model_fetching:
return
if voice_settings.model_installed(name):
self._set_voice("model", name, f"Whisper model: {name}")
else:
self._fetch_model(name, then_choose=True)
def _fetch_model(self, name, then_choose=False):
"""voice/models.sh model NAME, on the host in the background (up to 574 MB); then the
model is chosen. ft-voiced restarts the whisper service when its model changes."""
if self._model_fetching:
return
self._model_fetching = name
self.voiceChanged.emit()
self.message.emit(f"Downloading whisper {name} ({voice_settings.WHISPER_MODELS[name][1]} MB)…", False)
def job():
result = host(os.path.join(REPO, "voice", "models.sh"), "model", name, timeout=3600)
if result.returncode != 0 or not voice_settings.model_installed(name):
err = (result.stderr.strip() or result.stdout.strip() or "no answer").splitlines()
self.message.emit(f"Couldn't download whisper {name}: " + (err[-1] if err else "")[:160], True)
elif then_choose:
s = voice_settings.load()
s["model"] = name
voice_settings.save(s)
self.message.emit(f"Whisper model: {name}, downloaded", False)
else:
self.message.emit(f"Whisper {name} downloaded", False)
self._model_fetching = "" # last: the page waits for this
self.voiceChanged.emit()
threading.Thread(target=job, daemon=True).start()
@Property("QVariantList", notify=voiceChanged)
def voiceCommands(self):
commands, _ = voice_commands.parse(voice_settings.load()["commands"])
@@ -1458,6 +1566,13 @@ class Backend(QObject):
"""The full calibration in the panel fixed to the headset (gaze/gazecheck.py)."""
self._gaze_check("calibrate", "Calibration: look at each dot in the headset; right click or Meta+K stops")
@Slot()
def openTour(self):
"""Frametop Tour (tour/ft_tour.py), in this container as it runs."""
ok, _pid = QProcess.startDetached(sys.executable, [os.path.join(REPO, "tour", "ft_tour.py")])
if not ok:
self.message.emit("Couldn't start Frametop Tour", True)
def _gaze_check(self, command, done):
if self._send(command, GAZED):
self._check_asked = time.monotonic()
+62 -7
View File
@@ -26,7 +26,9 @@ Kirigami.ApplicationWindow {
Kirigami.Action { text: "Ignored panels"; icon.name: "view-hidden"; onTriggered: root.show(ignorePage) },
Kirigami.Action { text: "Gaze"; icon.name: "view-visible"; onTriggered: root.show(gazePage) },
Kirigami.Action { text: "Hands"; icon.name: "transform-browse"; onTriggered: root.show(handsPage) },
Kirigami.Action { text: "Bluetooth"; icon.name: "preferences-system-bluetooth"; onTriggered: root.show(bluetoothPage) }
Kirigami.Action { text: "Bluetooth"; icon.name: "preferences-system-bluetooth"; onTriggered: root.show(bluetoothPage) },
Kirigami.Action { separator: true },
Kirigami.Action { text: "Frametop Tour"; icon.name: "help-about"; onTriggered: backend.openTour() }
]
}
@@ -371,7 +373,12 @@ Kirigami.ApplicationWindow {
}
Controls.ComboBox {
model: backend.roles
// Keyboard + 3D pointer only where it splits something: a mouse node and a keyboard
// node. A device that isn't connected has no nodes to tell, so it's offered there too.
model: backend.roles.filter(r => r.value !== "combo" || row.modelData.role === "combo"
|| !row.modelData.connected
|| (row.modelData.kinds.indexOf("mouse") >= 0
&& row.modelData.kinds.indexOf("keyboard") >= 0))
textRole: "text"
valueRole: "value"
Component.onCompleted: currentIndex = indexOfValue(row.modelData.role)
@@ -423,7 +430,7 @@ Kirigami.ApplicationWindow {
}
]
property string deviceId: pointerDevices.length > 0 ? pointerDevices[Math.max(0, deviceBox.currentIndex)].id : ""
property var pointerDevices: backend.devices.filter(d => d.role === "pointer")
property var pointerDevices: backend.devices.filter(d => d.role === "pointer" || d.role === "combo")
property int capturedCode: -1
property string capturedName: ""
property bool capturing: false
@@ -724,7 +731,7 @@ Kirigami.ApplicationWindow {
title: "Keyboard"
// Pass-through keyboards connected now: with "no_keyboard", the keyboard waits for none.
// A program's uinput keyboard (a remote desktop's, say) doesn't count.
property var keyboards: backend.devices.filter(d => d.connected && d.role === "passthrough"
property var keyboards: backend.devices.filter(d => d.connected && (d.role === "passthrough" || d.role === "combo")
&& d.kinds.indexOf("keyboard") >= 0 && !d.uinput)
Kirigami.FormLayout {
@@ -896,6 +903,18 @@ Kirigami.ApplicationWindow {
icon.name: "input-keyboard"
onClicked: root.show(keyboardPage)
}
Controls.ComboBox {
Kirigami.FormData.label: "Keys do:"
model: backend.dictationKeyModes
textRole: "text"
valueRole: "value"
enabled: backend.dictationKeys.length > 0
displayText: backend.dictationKeyMode ? currentText : "Some tap, some are held"
readonly property string want: backend.dictationKeyMode
onWantChanged: currentIndex = indexOfValue(want)
Component.onCompleted: currentIndex = indexOfValue(want)
onActivated: backend.setDictationKeyMode(currentValue)
}
Repeater {
model: backend.voiceButtons
delegate: RowLayout {
@@ -947,6 +966,41 @@ Kirigami.ApplicationWindow {
onActivated: backend.setVoiceControllerMode(currentValue)
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Language" }
Controls.ComboBox {
Kirigami.FormData.label: "You speak:"
model: backend.voiceLanguages
textRole: "text"
valueRole: "value"
Layout.preferredWidth: Kirigami.Units.gridUnit * 12
readonly property string want: vpage.vs.language
onWantChanged: currentIndex = indexOfValue(want)
Component.onCompleted: currentIndex = indexOfValue(want)
onActivated: backend.setVoiceLanguage(currentValue)
}
Controls.ComboBox {
Kirigami.FormData.label: "Whisper model:"
model: backend.voiceModels
textRole: "text"
valueRole: "value"
enabled: !backend.voiceModelFetching
displayText: backend.voiceModelFetching ? "Downloading " + backend.voiceModelFetching + "…" : currentText
Layout.preferredWidth: Kirigami.Units.gridUnit * 18
readonly property string want: backend.voiceModel
onWantChanged: currentIndex = indexOfValue(want)
onModelChanged: currentIndex = indexOfValue(want)
Component.onCompleted: currentIndex = indexOfValue(want)
onActivated: backend.setVoiceModel(currentValue)
}
Controls.Label {
text: vpage.vs.language === "en"
? "Moonshine types English as you talk. Whisper types it when \"As you talk\" is off, and hears the wake word's commands."
: "Whisper types other languages, slower than Moonshine types English. Bigger models hear better and take longer; large-v3-turbo can make VR stutter."
wrapMode: Text.Wrap
opacity: 0.7
Layout.maximumWidth: Kirigami.Units.gridUnit * 24
}
Kirigami.Separator { Kirigami.FormData.isSection: true; Kirigami.FormData.label: "Typing" }
Controls.Switch {
Kirigami.FormData.label: "As you talk:"
@@ -994,8 +1048,8 @@ Kirigami.ApplicationWindow {
opacity: 0.7
text: "Speech is recognised on the headset; nothing leaves it. Text is typed where you type: the "
+ "desktop window you clicked last, or Steam and games. It waits while you hold Ctrl, Alt, "
+ "Shift or Meta, so letters don't turn into shortcuts. Recordings under 2 seconds are "
+ "dropped. A key works only on its own: with another modifier held it's a shortcut, not "
+ "Shift or Meta, so letters don't turn into shortcuts. A key held to talk "
+ "dictates while it's down; pressing another key with it cancels. A key works only on its own: with another modifier held it's a shortcut, not "
+ "dictation. Controller buttons still do what they do in games. After the wake word, a "
+ "command goes to voice/hooks/on-command, or runs a program you set in "
+ "~/.config/frametop-voice.json (\"commands\"; see voice/README.md)."
@@ -1306,7 +1360,8 @@ Kirigami.ApplicationWindow {
text: (backend.pauseGesture.first === "" ? "No gesture: a mapped button, a key combination, or ft-pause still work."
: "Pauses and resumes Frametop, in games too. The game still gets the presses, so pick buttons it "
+ "doesn't use this way. Two buttons count as together when both go down within a third of a "
+ "second, and twice means within 0.7 s.")
+ "second, and twice means within 0.7 s. A gesture with a button on each controller works with "
+ "one controller too: while only one is connected, its button alone, clicked twice, does it.")
+ (gmpage.st.relay && backend.pauseGesture.first !== "" && !gmpage.st.gestureReader
? " Not reading the controllers right now (SteamVR isn't running?)." : "")
}
+52 -8
View File
@@ -26,7 +26,10 @@ Toggled by:
games (the pointer helper only reads those outside games).
- A controller gesture, by default both thumbsticks clicked together twice ("pause_gesture").
It's read from vrserver's web socket (vrws.py), which works in games and takes nothing from
them, so the game sees the clicks too.
them, so the game sees the clicks too. Once it goes off, it's ignored for GESTURE_COOLDOWN:
on 2026-10-16 a resume was followed by a pause 2 s later, which left Frametop paused.
A gesture on both controllers, with only one of them connected, is that controller's button
alone, clicked twice (one_controller).
- input/ft-pause on|off|toggle|status, which uses the relay's control socket
("pause on|off|toggle|?").
- VR games, with "pause_auto" (on by default): the pointer helper says "vrgame 1|0" as a SteamVR
@@ -39,7 +42,7 @@ Settings in ~/.config/frametop-input.json (Frametop Input Settings, Game optimiz
(bool), "pause_gesture" ({"buttons": one or two controller buttons, "presses": 1 or 2}, or null
for none; one button always takes two presses), "pause_desktop" ("hide" or "close"), and
"pause_sound" (a sound on pause and resume; bool). The state outlives a relay restart in
STATE_PATH.
STATE_PATH, and a desktop that starts while paused is told (screens_seen), so it hides too.
"""
import json
import os
@@ -77,6 +80,7 @@ RESUME_DELAY = 5.0 # after a VR game ends: loading the next one can end one sce
GAME_STALE = 12.0 # the helper repeats "vrgame" every 5 s; silence this long means no game (or no SteamVR)
CHORD_WINDOW = 0.3 # a two-button gesture's buttons go down within this of each other
DOUBLE_WINDOW = 0.7 # a double press's second press comes within this of the first
GESTURE_COOLDOWN = 3.0 # once the gesture goes off, it's ignored this long (a triple press, a repeat)
LOOKUP_EVERY = 30.0 # the controllers are looked up again this often (and sooner when they may have changed)
LOOKUP_GAP = 3.0 # but not more often than this for a message from a device it doesn't know
@@ -103,6 +107,17 @@ def gesture_of(rules, buttons):
return chosen, 1 if len(chosen) == 2 and spec.get("presses") == 1 else 2
def one_controller(buttons, sides):
"""A two-button gesture with a button on each controller, while only one of the two is connected
(`sides`: the sides there are): that one's button alone, pressed twice, so a single controller
can still pause. None otherwise."""
by_side = {b.split("/", 1)[0]: b for b in buttons}
if len(buttons) != 2 or len(by_side) != 2:
return None
there = [b for side, b in by_side.items() if side in sides]
return (there[0],) if len(there) == 1 else None
def settings_of(rules, buttons):
return {"auto": rules.get("pause_auto", True) is not False,
"gesture": gesture_of(rules, buttons),
@@ -114,13 +129,14 @@ class Gesture:
"""The pause gesture, from button presses and releases. A press of a two-button gesture is
both buttons going down within CHORD_WINDOW of each other, so a button held for a while (to
sprint, say) and the other clicked doesn't count. Two presses within DOUBLE_WINDOW make a
double press."""
double press. Once it goes off, it's ignored for GESTURE_COOLDOWN."""
def __init__(self, buttons, presses):
self.buttons, self.presses = tuple(buttons), presses
self.down = {} # button -> when it went down
self.first = None # when the first press of a double press was
self.fired = None
self.ignored = None # a gesture the cooldown dropped: how long after the last one (for the log)
def reset(self):
self.down.clear()
@@ -142,8 +158,9 @@ class Gesture:
self.first = now
return False
self.first = None
if self.fired is not None and now - self.fired < 1.0:
return False # a triple press is still one
if self.fired is not None and now - self.fired < GESTURE_COOLDOWN:
self.ignored = now - self.fired
return False
self.fired = now
return True
@@ -303,6 +320,7 @@ class ControllerWatch(threading.Thread):
last_poll = 0.0
self.tracker = TouchTracker()
touch_was = False
alone = None # the one-controller gesture in use (one_controller), or None
while True:
g = self.current()
touch = self.touch_feed
@@ -341,8 +359,17 @@ class ControllerWatch(threading.Thread):
self.log(f"{'pause gesture' if g is not None else 'touch feed'}: following "
f"{' and '.join(sorted(sides.values())) or 'no'} "
f"controller{'s' if len(sides) != 1 else ''} on SteamVR's web socket")
wanted = {} if g is None else \
{b: (b.split("/", 1)[0], f"/input/{b.split('/', 1)[1]}/click") for b in g.buttons}
single = None if g is None else one_controller(g.buttons, set(sides.values()))
if single is None:
if alone is not None and g is not None:
self.log(f"pause gesture: both controllers are there again, back to {' and '.join(g.buttons)}")
alone = None
elif alone is None or alone.buttons != single:
alone = Gesture(single, 2)
self.log(f"pause gesture: only one controller, so {single[0]} clicked twice")
use = alone or g
wanted = {} if use is None else \
{b: (b.split("/", 1)[0], f"/input/{b.split('/', 1)[1]}/click") for b in use.buttons}
for text in ws.messages(timeout=1.0):
device = device_of(text)
if device and device not in sides:
@@ -363,12 +390,15 @@ class ControllerWatch(threading.Thread):
self.to_helper(self.tracker.feed(msg.get("device"), components))
for button, (s, component) in wanted.items():
if s == side and component in components:
if g.feed(button, bool(components[component]), time.monotonic()):
if use.feed(button, bool(components[component]), time.monotonic()):
self.log("pause gesture")
try:
self.sock.sendto(b"pause toggle gesture", RELAY)
except OSError:
pass
elif g.ignored is not None:
self.log(f"pause gesture ignored: {g.ignored:.1f} s after the last one")
g.ignored = None
finally:
ws.close()
@@ -508,6 +538,7 @@ class GamePause:
self.hands_always = False # HANDS_MODE=always: hand tracking runs through games (set_hands)
self.dashboard = False # the SteamVR dashboard is open (the helper's "vrdash")
self.parked = None # hand tracking parked, as last applied (None: not yet)
self.screens_pid = None # the ft-screens that last said where typing goes (screens_seen)
self.screens = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_NONBLOCK)
self.worker = Worker(log, self.save)
self.watch = ControllerWatch(log)
@@ -568,6 +599,19 @@ class GamePause:
except OSError:
pass # the desktop isn't running
def screens_seen(self, pid):
"""From the relay: ft-screens `pid` said where typing goes, as it does every second. One
that started while paused never heard "pause on" and shows its screens, while the relay
keeps the 3D mouse off: on 2026-10-16 an update restarted the desktop and the relay, the
relay came back paused from STATE_PATH, and the desktop looked on with a dead pointer.
So a new ft-screens hears the pause."""
if not pid or pid == self.screens_pid:
return
self.screens_pid = pid
if self.paused:
self.log(f"telling ft-screens ({pid}) that Frametop is paused")
self.tell_screens(True)
def tell_helper(self):
"""The pointer helper's hand interaction runs in a VR game only while Frametop isn't paused
(toggled back on during the game) or the dashboard is open: "paused 1|0", on a change and
+360 -45
View File
@@ -10,11 +10,14 @@ the physical devices as they come and go.
Every USB or Bluetooth mouse and keyboard is a candidate. A physical device is
identified by its Bluetooth address (EVIOCGUNIQ) or USB bus:vendor:product:name,
so all its event nodes share one role (the Swiftpoint Z3 has a mouse node and a
keyboard node for its extra buttons). Roles, from ~/.config/frametop-input.json
(written by the Frametop Input Settings app):
keyboard node for its extra buttons), except with "combo". Roles, from
~/.config/frametop-input.json (written by the Frametop Input Settings app):
pointer grabbed; drives the universal 3D mouse (default for devices with a mouse node)
passthrough keys go to the desktop; grabbed only while typing goes there, otherwise only observed,
for the key combinations below (default for keyboards)
combo a keyboard with a touchpad (its touchpad is a mouse node): mouse nodes are pointer,
keyboard nodes passthrough, as if they were two devices. Never a default: the
Z3's keyboard node has to stay with its mouse, and nothing on the nodes tells the two apart
ignore not grabbed, only observed for identification in the settings app
Buttons and keys of pointer devices go through a per-device map to actions
(left, right, middle, back, scroll_up, scroll_down, dashboard, recenter,
@@ -50,7 +53,10 @@ either side, here Ctrl+Alt+G): the combination does the action, and its last key
A modifier on its own ("125": Meta) is a tap: pressed with no other modifier held, and released
with no other key, mouse button, or scroll in between; the desktop gets an F24 press before its
release, so Plasma's launcher doesn't open on a Meta tap that's bound. A tap's left-hand code is
either side's; a right-hand code ("97": Right Ctrl) is that key's alone, and comes first. A rules
either side's; a right-hand code ("97": Right Ctrl) is that key's alone, and comes first. A lone
modifier bound to dictate_hold is hold to talk instead: dictation starts on its press (alone) and
stops on its release, and a key, mouse button or scroll in between makes it a shortcut, which
cancels that dictation quietly ("cancel keyboard quiet"). A rules
file without "key_bindings" gets DEFAULT_KEY_BINDINGS (Meta tap: steam_menu, Meta+J: gaze_left,
Meta+K: gaze_right, Meta+Shift+F: float_toggle), plus a Right Ctrl tap for dictate once Frametop
Voice is installed (VOICE_KEY_BINDINGS); one with its own, even an empty one, doesn't. The float,
@@ -82,16 +88,21 @@ Back) goes there too, and ft-screens gives it the screen the pointer is on, not
typing goes to (it releases the button itself when the pointer leaves the screens, they
hide, or Frametop pauses). Other buttons and keys from KEY_OK up don't go there.
Typing goes to the panel clicked last, and ft-screens says which ("keyboard
desktop|steam" on the control socket, every second). While it's the desktop,
desktop|steam SHOWN" on the control socket, every second; SHOWN 1|0, whether the screens
show). While it's the desktop,
pass-through keyboards are grabbed, so gamescope, which reads every keyboard itself,
doesn't type them into its focused app too. Without word from ft-screens for 3 seconds
they're released.
Dictation (Frametop Voice) types here: its text comes as "type TEXT" on the control socket, only
from this user, and goes out key by key, TYPE_STEP apart, where typing goes: to ft-screens for the
desktop, else on the virtual keyboard, which SteamVR passes on to Steam and games (US layout,
ASCII; the voice service turns the rest into ASCII or drops it). A character waits while a
modifier is held on a keyboard, which would make it a shortcut (Ctrl+letter).
desktop, else on the virtual keyboard, which SteamVR passes on to Steam and games. On the
desktop each character is the keys that type it on the desktop's keyboard layout (Plasma's
kxkbrc in Frametop's desktop config, worked out with libxkbcommon), Shift and AltGr included, so a
German QWERTZ desktop gets "z" from the Z key; Steam and games get a US layout. A character the
layout has no key for (a dead key's accent, another script) is dropped, and the log says which.
A character waits while a modifier is held on a keyboard, which would make it a shortcut
(Ctrl+letter).
Frametop's keyboard (ft-screens' key panel): the desktop's input method (input/ft-textinput)
says "textfield 1|0" when a text field on the desktop gains or loses keyboard focus, and
@@ -103,6 +114,18 @@ doesn't count), "button" (only the keyboard_toggle action opens it), or "never"
open when the text field loses focus, until its Close key, keyboard_toggle, or a layout reset
(ft-layout reset) closes it.
Full Keyboard (keyboard/, TaiKeid's, run with --frametop; keyboard/FRAMETOP.md) takes over from
it while it runs. It says "kbhello OPEN" on the control socket every second, only taken from
this user, and gets "kb DESKTOP SURFACE" back: whether typing goes to the desktop, and whether
the screens show with Frametop not paused (it may show over them with the dashboard closed).
The relay tells ft-screens "fullkb OPEN", and while that's fresh ft-screens passes its
"vrkeyboard" commands on as "kbpanel show|hide", which the relay sends Full Keyboard as "kb
show|hide". Its typing for the desktop comes as "kbkey CODE 0|1" and "kbtext TEXT", only from
the address that said hello: they go in order with dictation's typing, keys as they are (a held
modifier doesn't hold them back), text through the desktop's layout like dictation's, and
unlogged. Its keys come up where they went down, and when it stops (no hello for 3 seconds),
the ones it holds come up.
Volume keys, from every device that has them (the headset's own buttons included),
are handled here: wpctl steps the default output. Nothing else may see a volume key,
because gamescope aborts on one when no window has keyboard focus, which ends the
@@ -166,6 +189,9 @@ kernel's evdev and uinput interfaces.
import array
import atexit
import collections
import configparser
import ctypes
import ctypes.util
import errno
import fcntl
import json
@@ -271,12 +297,22 @@ KEY_F24 = 194 # sent to the desktop with a Meta combination (see the top)
# Key combinations ("key_bindings"): modifiers, each side's code folded into the left one's.
MODIFIERS = {29: 29, 97: 29, 42: 42, 54: 42, 56: 56, 100: 56, 125: 125, 126: 125}
KEY_LEFTSHIFT = 42
# Dictation's text, typed on a US layout (see the top): character -> (code, with Shift).
US_KEYS = {" ": (57, False)}
KEY_RIGHTALT = 100 # AltGr: the third level of most layouts
# Dictation's text on a US layout (see the top), for Steam and games, and the desktop without a
# layout of its own: character -> (code, modifiers held while it's pressed).
US_KEYS = {" ": (57, ())}
for _row, _shifted, _first in (("1234567890-=", "!@#$%^&*()_+", 2), ("qwertyuiop[]", "QWERTYUIOP{}", 16),
("asdfghjkl;'`", 'ASDFGHJKL:"~', 30), ("\\zxcvbnm,./", "|ZXCVBNM<>?", 43)):
for _i, (_plain, _shift) in enumerate(zip(_row, _shifted)):
US_KEYS[_plain], US_KEYS[_shift] = (_first + _i, False), (_first + _i, True)
US_KEYS[_plain], US_KEYS[_shift] = (_first + _i, ()), (_first + _i, (KEY_LEFTSHIFT,))
# The desktop's keyboard layout: Plasma's kxkbrc in the Frametop desktop's config folder (its
# XDG_CONFIG_HOME, session/frametop-session.sh), where its System Settings saves it.
KXKBRC = os.path.expanduser("~/.config/frametop/kxkbrc")
# The keys tried for each character on it, with these held (the first that types it wins): the
# main block and the key left of Z on ISO keyboards (KEY_102ND: "<" on a German one), not the
# keypad, which some apps read as keys of their own.
LAYOUT_CODES = (*range(2, 54), 57, 86)
LAYOUT_MODS = ((), (KEY_LEFTSHIFT,), (KEY_RIGHTALT,), (KEY_LEFTSHIFT, KEY_RIGHTALT))
TYPE_STEP = 0.004 # seconds from a typed key's press to its release, and on to the next key
TYPE_MAX = 20000 # characters waiting to be typed, at most
VR_KEYBOARD_MODES = ("always", "no_keyboard", "button", "never") # when Frametop's keyboard opens
@@ -419,6 +455,107 @@ def read_config(path=os.path.expanduser("~/.config/frametop.conf")):
return conf
def kxkbrc_layout(path=KXKBRC):
"""The desktop's keyboard layout as (layout, variant, options, model): the first of kxkbrc's
LayoutList and VariantList; None when there's no file, no list, or layouts are switched off
there (Use=false: KWin's default, a US layout, then)."""
cp = configparser.ConfigParser(interpolation=None, strict=False)
cp.optionxform = str # its keys are case-sensitive
try:
if not cp.read(path):
return None
except configparser.Error:
return None
sec = cp["Layout"] if cp.has_section("Layout") else {}
def first(key):
return (sec.get(key) or "").split(",")[0].strip()
if (sec.get("Use") or "true").strip().lower() == "false" or not first("LayoutList"):
return None
options = sec.get("Options", "") if (sec.get("ResetOldOptions") or "").strip().lower() == "true" else ""
return first("LayoutList"), first("VariantList"), options.strip(), (sec.get("Model") or "pc105").strip()
class XkbNames(ctypes.Structure):
_fields_ = [("rules", ctypes.c_char_p), ("model", ctypes.c_char_p), ("layout", ctypes.c_char_p),
("variant", ctypes.c_char_p), ("options", ctypes.c_char_p)]
def layout_keys(layout, variant="", options="", model="pc105"):
"""{character: (code, modifiers)} for a keyboard layout, through libxkbcommon: every key in
LAYOUT_CODES with each of LAYOUT_MODS held, and what it types. Dead keys type nothing, so
their accents aren't in it. Raises OSError without libxkbcommon or a keymap for the layout."""
lib = ctypes.CDLL(ctypes.util.find_library("xkbcommon") or "libxkbcommon.so.0")
lib.xkb_context_new.restype = ctypes.c_void_p
lib.xkb_context_new.argtypes = [ctypes.c_int]
lib.xkb_context_unref.argtypes = [ctypes.c_void_p]
lib.xkb_keymap_new_from_names.restype = ctypes.c_void_p
lib.xkb_keymap_new_from_names.argtypes = [ctypes.c_void_p, ctypes.POINTER(XkbNames), ctypes.c_int]
lib.xkb_keymap_unref.argtypes = [ctypes.c_void_p]
lib.xkb_state_new.restype = ctypes.c_void_p
lib.xkb_state_new.argtypes = [ctypes.c_void_p]
lib.xkb_state_unref.argtypes = [ctypes.c_void_p]
lib.xkb_state_update_key.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int]
lib.xkb_state_key_get_utf8.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_char_p, ctypes.c_size_t]
ctx = lib.xkb_context_new(0)
if not ctx:
raise OSError("libxkbcommon: no context")
names = XkbNames(b"evdev", model.encode() or None, layout.encode(), variant.encode() or None,
options.encode() or None)
keymap = lib.xkb_keymap_new_from_names(ctx, ctypes.byref(names), 0)
if not keymap:
lib.xkb_context_unref(ctx)
raise OSError(f"no keymap for layout {layout}{f'({variant})' if variant else ''}")
keys = {}
buf = ctypes.create_string_buffer(16)
try:
for mods in LAYOUT_MODS:
state = lib.xkb_state_new(keymap)
for m in mods:
lib.xkb_state_update_key(state, m + 8, 1) # evdev code + 8: the XKB keycode; 1: down
for code in LAYOUT_CODES:
n = lib.xkb_state_key_get_utf8(state, code + 8, buf, len(buf))
ch = buf.raw[:n].decode(errors="ignore") if 0 < n < len(buf) else ""
if len(ch) == 1 and ch.isprintable() and ch not in keys:
keys[ch] = (code, mods)
lib.xkb_state_unref(state)
finally:
lib.xkb_keymap_unref(keymap)
lib.xkb_context_unref(ctx)
return keys
class DesktopLayout:
"""The keys that type dictation's characters on the desktop (layout_keys), made again when
kxkbrc changes; US_KEYS without a layout or libxkbcommon."""
def __init__(self, path=None):
self.path, self.stamp, self.keys, self.name = path, False, US_KEYS, "us" # path None: KXKBRC
def refresh(self):
path = self.path or KXKBRC
try:
st = os.stat(path)
stamp = (st.st_mtime_ns, st.st_size)
except OSError:
stamp = None
if (path, stamp) == self.stamp:
return
self.stamp = (path, stamp)
found = kxkbrc_layout(path) if stamp else None
name = f"{found[0]}({found[1]})" if found and found[1] else found[0] if found else "us"
keys = US_KEYS
if found and name != "us":
try:
keys = layout_keys(*found)
except OSError as e:
log(f"typing on the desktop: {e}; a US layout instead")
name = "us"
if name != self.name or keys is not self.keys:
log(f"typing on the desktop: layout {name}")
self.name, self.keys = name, keys
def default_key_bindings():
"""The key combinations a rules file without its own gets."""
return {**DEFAULT_KEY_BINDINGS, **(VOICE_KEY_BINDINGS if os.path.exists(VOICE_UNIT) else {})}
@@ -843,6 +980,18 @@ class Node:
"kinds": kinds, "role": self.role, "grabbed": self.grabbed, "uinput": self.uinput}
def node_role(rule, node, nodes):
"""A candidate node's role, from its device's rule in frametop-input.json ({} for none) and
the nodes there are: "combo" splits the device by node (see the top), the other roles
take every node of it, and without one a device with a mouse node is a pointer."""
if rule.get("role") == "combo":
return "pointer" if node.is_mouse else "passthrough"
if rule.get("role") in ("pointer", "passthrough", "ignore"):
return rule["role"]
has_mouse = any(n.is_mouse for n in nodes if n.id == node.id) or node.is_mouse
return "pointer" if has_mouse else "passthrough"
def probe(path):
"""Open a node if it is a USB or Bluetooth mouse or keyboard, or has volume keys,
else return None."""
@@ -904,9 +1053,13 @@ def main():
# vr_capture_until: every controller button is taken until then (the settings app capturing one).
# pointer: the 3D mouse while it's in use, pointer_conf: the one the config asks for (they
# differ while Frametop is paused).
# shown_until: ft-screens shows the desktop's screens until then (it says so with "keyboard").
# kb_addr, kb_until: Full Keyboard's address, and until when it counts as running (its
# hello renews it); kb_said: the "kb DESKTOP SURFACE" it was told last.
state = {"pointer": None, "pointer_conf": None, "rules": {},
"desktop_until": 0.0, "typing_applied": None, "vr_capture_until": 0.0, "vr_bind_retry": False,
"pointer_holding": False, "calpanel_until": 0.0}
"pointer_holding": False, "calpanel_until": 0.0, "shown_until": 0.0,
"kb_addr": None, "kb_until": 0.0, "kb_said": None}
def pause_changed(paused):
"""Frametop paused or resumed (game_pause.py): the relay's own part."""
@@ -1079,6 +1232,8 @@ def main():
held_modifiers = set() # on any keyboard, folded (MODIFIERS)
tap_code = None # the tap's key as it is (a right-hand code isn't folded), see modifier_tap
talk_code = None # a modifier bound to dictate_hold, held alone: dictation runs until its release
talk_hidden = False # its press kept from the desktop, so the text typed meanwhile isn't a shortcut
held_meta = set() # the Meta keys held, as they are (KEY_LEFTMETA, KEY_RIGHTMETA)
meta_hidden = set() # held Meta keys the desktop was told came up (a combination; key_binding)
combos_down = {} # key code -> the action its combination started (released with it)
@@ -1105,22 +1260,42 @@ def main():
def key_binding(code, value, now):
"""A key from a keyboard: does it complete a key combination ("key_bindings")? True if
it was taken for one (then it isn't typed)."""
nonlocal tap, tap_code
nonlocal tap, tap_code, talk_code, talk_hidden
if code in MODIFIERS:
mod = MODIFIERS[code]
hide = value == 2 and code == talk_code and talk_hidden # its repeats, as its press
if value == 1:
if talk_code is not None and code != talk_code:
cancel_talk() # another modifier with it: a shortcut (Ctrl+Shift+T)
tap = mod if not held_modifiers and not combos_down else None
tap_code = code
if tap is not None and talk_code is None and tap_action(mod, code)[1] == "dictate_hold":
# Hold to talk: dictation starts on the press, not the release. The desktop
# doesn't get the press (cancel_talk sends it after all for a shortcut), so
# live text is typed there while it's held. Steam and games read the keyboard
# themselves: there the text waits for the release (typing_tick).
talk_code, talk_hidden, hide = code, True, True
do_action("dictate_hold", 1, now, "keyboard")
log(f"key combination {tap_action(mod, code)[0]}: dictate_hold (held)")
(held_modifiers.add if value else held_modifiers.discard)(mod)
if code in (KEY_LEFTMETA, KEY_RIGHTMETA):
(held_meta.add if value else held_meta.discard)(code)
if value == 0 and code in meta_hidden:
meta_hidden.discard(code)
return True # the desktop already had it come up (below)
if value == 0 and code == talk_code:
talk_code, tap = None, None
hide, talk_hidden = talk_hidden, False
if not hide:
# As with a tap (modifier_tap): a key in between, so a Meta release isn't one.
to_screens(KEY_F24, 1)
to_screens(KEY_F24, 0)
do_action("dictate_hold", 0, now, "keyboard")
return hide
if value == 0 and tap == mod:
tap = None
modifier_tap(mod, tap_code, now)
return False
return hide
if value == 1 and code not in combos_down and meta_hidden:
# Another key while Meta is still held after a combination: the desktop gets Meta
# back first, so Meta+that key still works there.
@@ -1154,16 +1329,35 @@ def main():
log(f"key combination {combo}: {action}")
return True
def modifier_tap(mod, code, now):
"""A modifier pressed and released alone: its binding, if it has one ("125": Meta tap).
A right-hand key's own binding ("97": Right Ctrl) comes before the folded one."""
def tap_action(mod, code):
"""A lone modifier's combination and action, (combo, None) when it does nothing now. A
right-hand key's own binding ("97": Right Ctrl) comes before the folded one."""
bindings = state["rules"]["key_bindings"]
combo = str(code) if code != mod and str(code) in bindings else str(mod)
action = bindings.get(combo)
if not known_action(action) or action in ("key", "none") or (pause.paused and not works_paused(action)):
return
if action == "dictate_hold":
action = "dictate" # a tap is over before it's held: it starts, and the next one stops
return combo, None
return combo, action
def cancel_talk():
"""A key, mouse button or scroll while a hold-to-talk modifier is held: it was a shortcut
(Ctrl+C), so the dictation it started is dropped, without its cancel tone."""
nonlocal talk_code, talk_hidden
if talk_code is not None:
if talk_hidden:
to_screens(talk_code, 1) # the desktop gets the modifier before the key it's for
talk_code, talk_hidden = None, False
try:
screens_sock.sendto(b"cancel keyboard quiet", VOICE)
except OSError:
pass
log("dictate_hold: cancelled (another key while held: a shortcut)")
def modifier_tap(mod, code, now):
"""A modifier pressed and released alone: its binding, if it has one ("125": Meta tap)."""
combo, action = tap_action(mod, code)
if action is None or action == "dictate_hold":
return # nothing, or hold to talk (key_binding), which started on the press
# The desktop gets a key in between before the release goes there, so the tap isn't one
# there too: a Meta tap would open Plasma's launcher.
to_screens(KEY_F24, 1)
@@ -1197,17 +1391,33 @@ def main():
next_scan = 0.0
volume = Volume()
typing = collections.deque() # (code, shift): dictation's characters still to type
typing_down = [] # the character pressed last, (code, shift, to the desktop): its release is next
# What's still to type, in order: a character (str) from dictation, ("quiet", character)
# from Full Keyboard (its text isn't logged), or ("key", code, value), a key Full Keyboard
# pressed or released.
typing = collections.deque()
typing_down = [] # the character pressed last, (code, modifiers, to the desktop): its release is next
typing_at = 0.0 # the next press or release goes then
untyped = [] # characters with no key where typing went, logged when the queue is empty (None: quiet)
kb_held = {} # keys Full Keyboard holds down: code -> to the desktop
desktop_layout = DesktopLayout()
def type_text(text):
"""Queue dictation's text (see the top)."""
keys = [US_KEYS[c] for c in text if c in US_KEYS]
def type_text(text, quiet=False):
"""Queue dictation's text (see the top), or with quiet, Full Keyboard's. Which keys type
it is worked out as it goes, where typing goes then."""
desktop_layout.refresh()
chars = [c for c in text if c.isprintable()]
room = max(0, TYPE_MAX - len(typing))
typing.extend(keys[:room])
log(f"typing {min(len(keys), room)} characters for Frametop Voice"
+ (f" ({len(keys) - room} dropped: too many waiting)" if len(keys) > room else ""))
typing.extend((("quiet", c) for c in chars[:room]) if quiet else chars[:room])
if not quiet or len(chars) > room:
log(f"typing {min(len(chars), room)} characters for {'Full Keyboard' if quiet else 'Frametop Voice'}"
+ (f" ({len(chars) - room} dropped: too many waiting)" if len(chars) > room else ""))
def report_untyped(desktop):
if untyped and not typing:
where = f"the desktop's layout ({desktop_layout.name})" if desktop else "a US layout (Steam)"
said = "".join(dict.fromkeys(c for c in untyped if c is not None))
log(f"typing: {len(untyped)} characters {where} has no key for" + (f": {said}" if said else ""))
untyped.clear()
def type_key(code, value, desktop):
if desktop:
@@ -1216,6 +1426,14 @@ def main():
keyboard.emit(EV_KEY, code, value)
keyboard.sync()
def typing_blocked(now):
"""A modifier is held (the text would be shortcuts), except a hold-to-talk key the
desktop never got, when typing goes there."""
held = held_modifiers
if talk_hidden and now < state["desktop_until"]:
held = held - {MODIFIERS[talk_code]}
return bool(held)
def typing_tick(now):
"""Release the character pressed last, or press the next, when it's time. A new one
waits while a modifier is held."""
@@ -1223,23 +1441,44 @@ def main():
if now < typing_at:
return
if typing_down:
code, shift, desktop = typing_down.pop()
code, mods, desktop = typing_down.pop()
type_key(code, 0, desktop)
if shift:
type_key(KEY_LEFTSHIFT, 0, desktop)
elif typing and not held_modifiers:
code, shift = typing.popleft()
for m in reversed(mods):
type_key(m, 0, desktop)
elif typing and typing[0][0] == "key":
# Full Keyboard's key, with its modifiers its own: a held one doesn't stop it. It
# comes up where it went down.
_, code, value = typing.popleft()
if value:
if code in kb_held:
return # down already
desktop = kb_held[code] = now < state["desktop_until"]
elif code in kb_held:
desktop = kb_held.pop(code)
else:
return # not down
type_key(code, value, desktop)
elif typing and not typing_blocked(now):
desktop = now < state["desktop_until"]
if shift:
type_key(KEY_LEFTSHIFT, 1, desktop)
keys = desktop_layout.keys if desktop else US_KEYS
item = typing.popleft()
ch = item if isinstance(item, str) else item[1]
if ch not in keys:
untyped.append(ch if isinstance(item, str) else None)
report_untyped(desktop)
return # the next one right away
code, mods = keys[ch]
for m in mods:
type_key(m, 1, desktop)
type_key(code, 1, desktop)
typing_down.append((code, shift, desktop))
typing_down.append((code, mods, desktop))
report_untyped(desktop)
else:
return
typing_at = now + TYPE_STEP
def typing_timeout(now, default):
if typing_down or (typing and not held_modifiers):
if typing_down or (typing and (typing[0][0] == "key" or not typing_blocked(now))):
return max(0.0, min(default, typing_at - now))
return default
@@ -1286,11 +1525,7 @@ def main():
return node.candidate
def role_of(node):
rule = state["rules"]["devices"].get(node.id, {})
if rule.get("role") in ("pointer", "passthrough", "ignore"):
return rule["role"]
has_mouse = any(n.is_mouse for n in nodes.values() if n.id == node.id) or node.is_mouse
return "pointer" if has_mouse else "passthrough"
return node_role(state["rules"]["devices"].get(node.id, {}), node, nodes.values())
def release_held(node):
for code in node.held:
@@ -1320,7 +1555,9 @@ def main():
if not screens_down and not held_meta and not held_modifiers:
return
down = physically_down()
for code in sorted(screens_down - down):
typed = {k for code, mods, desktop in typing_down if desktop for k in (code, *mods)} # dictation's, coming up next
typed |= {code for code, desktop in kb_held.items() if desktop} # Full Keyboard's
for code in sorted(screens_down - down - typed):
to_screens(code, 0)
log(f"key {code} released on the desktop: no keyboard holds it")
for code in [c for c in held_meta if c not in down]:
@@ -1383,14 +1620,46 @@ def main():
except OSError:
watchers.pop(addr, None)
def kb_tell(now, message=None):
"""Tell Full Keyboard message, or with none, "kb DESKTOP SURFACE" if that changed: typing
goes to the desktop 0|1, and the screens show with Frametop not paused 0|1 (it may show
over them with the dashboard closed). From the control socket: it takes word from
@frametop_relay only."""
addr = state["kb_addr"]
if addr is None:
return
if message is None:
message = (f"kb {int(now < state['desktop_until'])}"
f" {int(now < state['shown_until'] and not pause.paused)}")
if message == state["kb_said"]:
return
state["kb_said"] = message
try:
control.sendto(message.encode(), addr)
except OSError:
pass # gone: its hellos stop, and kb_gone follows
def kb_gone(why):
"""Full Keyboard stopped or restarted: its keys still to come are dropped, and the keys
it holds come up where they went down."""
state["kb_addr"], state["kb_said"] = None, None
kept = [item for item in typing if isinstance(item, str) or item[0] != "key"]
typing.clear()
typing.extend(kept)
for code, desktop in sorted(kb_held.items()):
type_key(code, 0, desktop)
kb_held.clear()
log(f"Full Keyboard {why}")
def handle_control(now):
while True:
try:
data, anc, _, addr = control.recvmsg(65536, socket.CMSG_SPACE(creds.size))
except BlockingIOError:
kb_tell(now) # what changed reaches Full Keyboard at once
return
uid = next((creds.unpack(d[:creds.size])[1] for level, kind, d in anc
if level == socket.SOL_SOCKET and kind == socket.SCM_CREDENTIALS), None)
pid, uid = next((creds.unpack(d[:creds.size])[:2] for level, kind, d in anc
if level == socket.SOL_SOCKET and kind == socket.SCM_CREDENTIALS), (None, None))
words = data.decode(errors="replace").split()
cmd = words[0] if words else ""
# One malformed datagram must not end the relay: it would drop every grab,
@@ -1399,8 +1668,12 @@ def main():
try:
if cmd == "keyboard":
# From ft-screens (unbound, no reply): where typing goes, repeated every second.
# "keyboard desktop|steam 1|0": the third word, whether the screens show.
desktop = len(words) > 1 and words[1] == "desktop" and not pause.paused
state["desktop_until"] = now + 3.0 if desktop else 0.0
state["shown_until"] = now + 3.0 if len(words) > 2 and words[2] == "1" else 0.0
if uid == os.getuid():
pause.screens_seen(pid) # a desktop started while paused hides too
continue
if cmd == "vrbtn" and len(words) == 3 and words[1] in VR_BUTTONS and words[2] in ("0", "1"):
vr_button(words[1], int(words[2]), now)
@@ -1435,6 +1708,44 @@ def main():
else:
log(f"type from another user ({uid}): ignored")
continue
if cmd == "kbhello":
# Full Keyboard (keyboard/, --frametop), every second and when it opens or
# closes: "kbhello OPEN". Only from this user and a bound address: what it
# types goes to the desktop. It gets the word on every hello, and ft-screens
# hears it's there ("fullkb OPEN"), so it opens Full Keyboard, not its own.
if uid != os.getuid() or not addr:
continue
if addr != state["kb_addr"]:
if state["kb_addr"] is not None:
kb_gone("restarted")
log("Full Keyboard connected")
state["kb_addr"], state["kb_until"], state["kb_said"] = addr, now + 3.0, None
kb_tell(now)
opened = "1" if len(words) > 1 and words[1] == "1" else "0"
try:
screens_sock.sendto(f"fullkb {opened}".encode(), SCREENS)
except OSError:
pass # ft-screens not running
continue
if cmd in ("kbkey", "kbtext"):
# Full Keyboard's typing, while it was told it goes to the desktop: "kbkey CODE
# 0|1" (a key's press or release) and "kbtext TEXT" (what its input methods
# composed). Only from the Full Keyboard that said hello. In order with what's
# still to type, where typing goes then.
if uid != os.getuid() or addr is None or addr != state["kb_addr"]:
continue
if cmd == "kbtext":
type_text(data[len(b"kbtext "):].decode(errors="replace"), quiet=True)
elif (len(words) == 3 and words[1].isdigit() and 0 < int(words[1]) < BTN_MISC
and words[2] in ("0", "1") and len(typing) < TYPE_MAX):
typing.append(("key", int(words[1]), int(words[2])))
continue
if cmd == "kbpanel" and len(words) == 2 and words[1] in ("show", "hide"):
# From ft-screens (unbound): open or close Full Keyboard, which it does for the
# "vrkeyboard" commands while Full Keyboard is there.
if uid == os.getuid():
kb_tell(now, f"kb {words[1]}")
continue
if cmd == "calpanel" and len(words) == 2:
# From the pointer helper (see the top). Only from this user: it takes Enter.
if uid == os.getuid():
@@ -1485,7 +1796,7 @@ def main():
if etype == EV_REL and now - node.last_watch < 0.05:
return # motion: enough for an activity light
node.last_watch = now
msg = {"t": "event", "id": node.id, "path": node.path, "name": node.name,
msg = {"t": "event", "id": node.id, "path": node.path, "name": node.name, "role": node.role,
"type": EV_NAMES.get(etype, str(etype)), "code": code, "value": value}
for addr, until in list(watchers.items()):
if now > until:
@@ -1558,6 +1869,9 @@ def main():
vr_bind(now)
volume.tick(now)
typing_tick(now)
if state["kb_addr"] is not None and now >= state["kb_until"]:
kb_gone("stopped (no word for 3 seconds)")
kb_tell(now)
pause.tick(now)
if state["vr_capture_until"] and now >= state["vr_capture_until"]:
vr_bind(now) # capture over: back to the mapped buttons
@@ -1587,6 +1901,7 @@ def main():
value, now)
if (etype == EV_KEY and value == 1 and code not in MODIFIERS) or (etype == EV_REL and code in SCROLLS):
tap = None # a key, button or scroll in between: a modifier's release isn't a tap
cancel_talk()
if etype == EV_KEY and ((node.remapped and code in VOLUME_ORIGINAL)
or (node.grabbed and code in VOLUME_STANDIN)):
volume.key(fd, code, value, now)
+171 -9
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Offline test of the input relay's key combinations and modifier taps, of which keys and
mouse buttons reach the desktop (ft-screens), and of dictation (Frametop Voice): the keys that
start and stop it, and the text it has the relay type.
mouse buttons reach the desktop (ft-screens), of dictation (Frametop Voice): the keys that
start and stop it, and the text it has the relay type, and of Full Keyboard's typing.
Runs the relay's main() against a fake pass-through keyboard, a fake mouse, and two fake nodes
with volume keys, a keyboard's Consumer Control node and the headset's gpio-keys (pipes), with
@@ -62,7 +62,7 @@ class StubPause:
def configure(self, *args):
pass
game_state = tick = helper_started = controllers_changed = set_hands = dashboard_state = configure
game_state = tick = helper_started = controllers_changed = set_hands = dashboard_state = screens_seen = configure
stub = {}
@@ -239,12 +239,13 @@ def to_relay(text):
c.close()
def keys_for(text):
"""The presses and releases that type text, as (code, value), Shift included."""
def keys_for(text, keys=None):
"""The presses and releases that type text, as (code, value), modifiers included: on a US
layout, or keys ({character: (code, modifiers)})."""
out = []
for ch in text:
code, shift = relay.US_KEYS[ch]
out += ([(42, 1)] if shift else []) + [(code, 1), (code, 0)] + ([(42, 0)] if shift else [])
code, mods = (keys or relay.US_KEYS)[ch]
out += [(m, 1) for m in mods] + [(code, 1), (code, 0)] + [(m, 0) for m in reversed(mods)]
return out
@@ -494,8 +495,19 @@ def tests():
key(CTRL, 1); key(CTRL, 0); key(RCTRL, 1); key(RCTRL, 0)
check("a Ctrl tap bound by its left code: either side", dictation(), ["toggle keyboard", "toggle keyboard"])
use({"97": "dictate_hold"})
key(RCTRL, 1); key(RCTRL, 0)
check("a tap bound to dictate_hold toggles (a tap is over before it's held)", dictation(), ["toggle keyboard"])
key(RCTRL, 1)
check("Right Ctrl bound to dictate_hold: starts on the press", dictation(), ["start keyboard"])
key(RCTRL, 0)
check("...and stops on the release", dictation(), ["stop keyboard"])
key(CTRL, 1); key(CTRL, 0)
check("...Left Ctrl doesn't", dictation(), [])
key(RCTRL, 1); key(F, 1)
check("Right Ctrl+F while held: a shortcut, so that dictation is cancelled quietly", dictation(),
["start keyboard", "cancel keyboard quiet"])
key(F, 0); key(RCTRL, 0)
check("...and its release does nothing more", dictation(), [])
key(SHIFT, 1); key(RCTRL, 1); key(RCTRL, 0); key(SHIFT, 0)
check("Right Ctrl with Shift held: not on its own, no dictation", dictation(), [])
use({"29+42+33": "dictate_hold"})
key(CTRL, 1); key(SHIFT, 1); key(F, 1)
check("Ctrl+Shift+F held (dictate_hold): starts", dictation(), ["start keyboard"])
@@ -539,6 +551,73 @@ def tests():
time.sleep(0.3)
check("type: to the desktop while typing goes there", typed(), [f"key {c} {v}" for c, v in keys_for("Ok.")])
check("...and not to the virtual keyboard", emitted(), [])
use({"97": "dictate_hold"})
dictation()
key(RCTRL, 1)
to_relay("type ok")
time.sleep(0.3)
check("held to talk: live text reaches the desktop while Right Ctrl is held, without it",
typed(), [f"key {c} {v}" for c, v in keys_for("ok")])
key(RCTRL, 2); key(RCTRL, 0)
check("...nor its repeats or release", typed(), [])
check("...and dictation ran from press to release", dictation(), ["start keyboard", "stop keyboard"])
key(RCTRL, 1); key(C, 1); key(C, 0); key(RCTRL, 0)
check("held to talk, then C: the desktop gets Right Ctrl before C (a shortcut after all)", typed(),
["key 97 1", f"key {C} 1", f"key {C} 0", "key 97 0"])
check("...and that dictation is cancelled quietly", dictation(), ["start keyboard", "cancel keyboard quiet"])
key(RCTRL, 1); key(SHIFT, 1)
check("held to talk, then Shift: cancelled, and the desktop gets both", (dictation(), typed()),
(["start keyboard", "cancel keyboard quiet"], ["key 97 1", f"key {SHIFT} 1"]))
key(SHIFT, 0); key(RCTRL, 0)
typed()
to_relay("keyboard steam")
key(RCTRL, 1)
to_relay("type ab")
time.sleep(0.3)
check("held to talk while typing goes to Steam (it sees the key itself): the text waits", emitted(), [])
key(RCTRL, 0)
time.sleep(0.3)
check("...until the release", emitted(), [("keyboard", c, v) for c, v in keys_for("ab")])
dictation()
to_relay("keyboard desktop")
use({})
# The desktop's own layout (kxkbrc): German QWERTZ, through libxkbcommon.
rc = os.path.join(OUT, "kxkbrc")
with open(rc, "w") as f:
f.write("[Layout]\nLayoutList=de,us\nUse=true\n")
relay.KXKBRC = rc
try:
de = relay.layout_keys("de")
except OSError as e:
de = None
print(f"skip German layout typing: {e}")
if de:
check("German layout: z is the US Y key, y the US Z key, @ is AltGr+Q, < the ISO key",
(de["z"], de["y"], de["@"], de["<"], de["\u00e4"]),
((21, ()), (44, ()), (16, (100,)), (86, ()), (40, ())))
to_relay("type way @ f\u00fcr")
time.sleep(0.3)
check("type on a German desktop: its keys, AltGr and umlauts included", typed(),
[f"key {c} {v}" for c, v in keys_for("way @ f\u00fcr", de)])
to_relay("type a\u00e9b")
time.sleep(0.3)
check("...a character it has no key for (a dead key's accent) is dropped", typed(),
[f"key {c} {v}" for c, v in keys_for("ab", de)])
check("...and the log says which", [m for m in LOG if m.startswith("typing: 1 characters")][-1:],
["typing: 1 characters the desktop's layout (de) has no key for: \u00e9"])
to_relay("keyboard steam")
to_relay("type way")
time.sleep(0.3)
check("...Steam and games still get a US layout", emitted(), [("keyboard", c, v) for c, v in keys_for("way")])
to_relay("keyboard desktop")
with open(rc, "w") as f:
f.write("[Layout]\nLayoutList=de\nUse=false\n")
to_relay("type way")
time.sleep(0.3)
check("layouts switched off in kxkbrc (Use=false): a US layout", typed(),
[f"key {c} {v}" for c, v in keys_for("way")])
relay.KXKBRC = os.path.join(OUT, "no-kxkbrc")
to_relay("keyboard steam")
check("an empty command isn't an action", relay.known_action("command: "), False)
@@ -619,6 +698,88 @@ def tests():
time.sleep(3.1) # no renewal: the claim lapses after 3 s
key(ENTER, 1); key(ENTER, 0)
check("panel claim lapsed: Enter is typed", (cal_keys(), typed()), ([], ["key 28 1", "key 28 0"]))
# Full Keyboard (keyboard/, --frametop): it says hello, hears where typing goes and whether
# the screens show, and types on the desktop through the relay.
use({})
kb = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
kb.bind("") # autobound, as Full Keyboard's
kb.settimeout(0.05)
def heard():
"""What Full Keyboard was sent since the last call."""
got = []
while True:
try:
got.append(kb.recv(256).decode())
except socket.timeout:
return got
def from_kb(text, sock=kb):
sock.sendto(text.encode(), f"\0{tag}_relay")
time.sleep(0.1)
typed(), emitted()
from_kb("kbhello 0")
check("Full Keyboard's hello: the word back (Steam, nothing shown)", heard(), ["kb 0 0"])
check("...and ft-screens hears it's there", [m for m in typed() if m.startswith("fullkb")], ["fullkb 0"])
check("...logged", [m for m in LOG if m.startswith("Full Keyboard")][-1:], ["Full Keyboard connected"])
to_relay("keyboard desktop 1")
time.sleep(0.1)
check("typing goes to the desktop, the screens show: it hears at once", heard(), ["kb 1 1"])
from_kb("kbhello 1")
check("a hello always gets the word", heard(), ["kb 1 1"])
check("...and ft-screens hears it's up", [m for m in typed() if m.startswith("fullkb")], ["fullkb 1"])
from_kb("kbkey 30 1")
from_kb("kbkey 30 0")
check("its keys reach the desktop", (typed(), emitted()), (["key 30 1", "key 30 0"], []))
LOG.clear()
from_kb("kbtext a\u00e9b")
from_kb(f"kbkey {ENTER} 1")
from_kb(f"kbkey {ENTER} 0")
check("its text through the desktop's layout, then its key, in order", typed(),
[f"key {c} {v}" for c, v in keys_for("ab")] + ["key 28 1", "key 28 0"])
check("...what the layout can't type isn't logged by name",
[m for m in LOG if m.startswith("typing")], ["typing: 1 characters the desktop's layout (us) has no key for"])
from_kb(f"kbkey {SHIFT} 1")
to_relay("keyboard steam 1")
time.sleep(0.1)
check("typing goes to Steam: it hears", heard(), ["kb 0 1"])
from_kb(f"kbkey {SHIFT} 0")
from_kb("kbkey 30 1")
from_kb("kbkey 30 0")
check("a key held from before comes up on the desktop", typed(), [f"key {SHIFT} 1", f"key {SHIFT} 0"])
check("...and new ones go to the virtual keyboard", emitted(), [("keyboard", 30, 1), ("keyboard", 30, 0)])
other = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
other.bind("")
to_relay("keyboard desktop 1")
from_kb("kbkey 30 1", other)
from_kb("kbtext x", other)
check("keys and text from another address are ignored", (typed(), emitted()), ([], []))
other.close()
heard()
to_relay("kbpanel show")
check("ft-screens' kbpanel show: Full Keyboard opens", heard(), ["kb show"])
to_relay("kbpanel hide")
check("...and hide", heard(), ["kb hide"])
pause("on")
time.sleep(0.1)
check("paused: typing to Steam, and not over the screens", heard(), ["kb 0 0"])
pause("off")
to_relay("keyboard desktop 1")
from_kb("kbhello 1")
heard()
from_kb("kbkey 30 1")
time.sleep(1.2) # the relay's once-a-second check (reconcile_desktop_keys)
check("its held key stays down on the desktop past a second", typed(), ["fullkb 1", "key 30 1"])
time.sleep(2.2) # no hello for 3 s
check("it stops saying hello: its held key comes up", typed(), ["key 30 0"])
check("...logged", [m for m in LOG if m.startswith("Full Keyboard")][-1:],
["Full Keyboard stopped (no word for 3 seconds)"])
from_kb("kbkey 30 1")
check("gone: its keys reach nothing until it says hello again", (typed(), emitted()), ([], []))
kb.close()
to_relay("keyboard steam")
use(None)
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
shutil.rmtree(OUT, ignore_errors=True)
@@ -627,4 +788,5 @@ def tests():
threading.Thread(target=tests, daemon=True).start()
sys.argv = [relay_path, "--no-grab"]
relay.KXKBRC = os.path.join(OUT, "no-kxkbrc") # a US desktop until the layout tests
relay.main()
+34 -1
View File
@@ -52,7 +52,12 @@ check("sticks 0.5 s apart, twice: nothing", presses(g, both(0, gap=0.5, hold=0.6
g = gp.Gesture((L, R), 2)
check("three double presses in a row: once", presses(g, both(0) + both(0.3) + both(0.6)), 1)
g = gp.Gesture((L, R), 2)
check("again after a second: twice in all", presses(g, both(0) + both(0.3) + both(2.0) + both(2.3)), 2)
check("again 2 s later (2026-10-16: resumed, then paused again): ignored",
presses(g, both(0) + both(0.3) + both(2.0) + both(2.3)), 1)
check("...and says how long after the last one", round(g.ignored, 1), 2.0)
g = gp.Gesture((L, R), 2)
check("again after the cooldown: twice in all",
presses(g, both(0) + both(0.3) + both(gp.GESTURE_COOLDOWN + 0.5) + both(gp.GESTURE_COOLDOWN + 0.8)), 2)
g = gp.Gesture((L, R), 2)
check("repeated down states count once",
presses(g, [(0, L, True), (0.01, L, True), (0.02, R, True), (0.03, R, True), (0.1, L, False), (0.1, R, False)]
@@ -65,6 +70,18 @@ check("one button double-clicked: goes off",
g = gp.Gesture(("right/b",), 2)
check("other buttons don't count", presses(g, [(0, "right/a", True), (0.2, "right/a", True)]), 0)
check("one controller: its stick alone", gp.one_controller((L, R), {"right"}), (R,))
check("one controller (left): its stick alone", gp.one_controller((L, R), {"left"}), (L,))
check("both controllers: the gesture as set", gp.one_controller((L, R), {"left", "right"}), None)
check("no controllers: nothing", gp.one_controller((L, R), set()), None)
check("two buttons on one controller: as set", gp.one_controller(("right/a", "right/b"), {"right"}), None)
check("one button: as set", gp.one_controller(("right/b",), {"right"}), None)
g = gp.Gesture(gp.one_controller((L, R), {"right"}), 2)
check("one controller: its stick clicked twice goes off",
presses(g, [(0, R, True), (0.1, R, False), (0.3, R, True), (0.4, R, False)]), 1)
g = gp.Gesture(gp.one_controller((L, R), {"right"}), 2)
check("one controller: its stick clicked once, nothing", presses(g, [(0, R, True), (0.1, R, False)]), 0)
VR = ("left/thumbstick", "right/thumbstick", "right/b", "right/a")
check("no pause_gesture: both sticks twice", gp.gesture_of({}, VR), ((L, R), 2))
check("pause_gesture null: none", gp.gesture_of({"pause_gesture": None}, VR), None)
@@ -208,6 +225,22 @@ p.toggle("gesture", 0)
q = gp.GamePause(lambda *a: None, changes.append, VR)
check("a relay restarted while paused stays paused", (q.paused, q.reason), (True, "gesture"))
check("and stops the services again", q.worker.jobs, ["enforce"])
told = []
q.tell_screens = told.append
q.screens_seen(4242)
check("the desktop that reports in first hears the pause (2026-10-16: it showed, the pointer was off)",
told, [True])
q.screens_seen(4242)
check("...once, not every second", told, [True])
q.screens_seen(None)
check("a sender without credentials changes nothing", told, [True])
q.screens_seen(4343)
check("a restarted desktop (new pid) hears it again", told, [True, True])
p.tell_screens = told.append
told.clear()
p.toggle("gesture", 1)
p.screens_seen(5151)
check("running: a new desktop hears nothing", told, [False])
# ---------------------------------------------------------------- hand tracking parked for games
parked = lambda: os.path.exists(gp.PARK_PATH) # noqa: E731
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Offline test of the input relay's device roles (node_role): the defaults, a saved role
taking every node of a device, and "combo" (Keyboard + 3D pointer) splitting a keyboard with a
touchpad by node. It loads input-relay.py without running it: no devices, sockets, or grabs.
input/test/roles-test.py [RELAY] (default: input/input-relay.py next to this folder)
"""
import importlib.util
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
relay_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "input-relay.py")
relay = importlib.util.module_from_spec(importlib.util.spec_from_loader("relay", loader=None))
relay.__file__ = os.path.abspath(relay_path)
sys.path.insert(0, os.path.dirname(os.path.abspath(relay_path))) # its game_pause and vrws
with open(relay_path) as f:
exec(compile(f.read(), relay_path, "exec"), relay.__dict__)
failures = []
def check(label, got, want):
ok = got == want
print(("ok " if ok else "FAIL ") + label + ("" if ok else f": got {got!r}, want {want!r}"), flush=True)
if not ok:
failures.append(label)
def node(name, uniq, is_mouse, is_keyboard, bus=relay.BUS_BLUETOOTH):
return relay.Node("/dev/input/event999", -1, name, bus, 0x045E, 0x005F, uniq, is_mouse, is_keyboard)
def roles(rule, nodes):
return [relay.node_role(rule, n, nodes) for n in nodes]
# The keyboard of 2026-10-16: "Bluetooth 5.1 Keyboard" and "... Keyboard Mouse" (its touchpad), one address.
combo = [node("Bluetooth 5.1 Keyboard", "d1:01:0b:5d:87:05", False, True),
node("Bluetooth 5.1 Keyboard Mouse", "d1:01:0b:5d:87:05", True, False)]
check("one device: both nodes have the address as id", {n.id for n in combo}, {"d1:01:0b:5d:87:05"})
check("no rule: the whole device is a pointer (it has a mouse node)", roles({}, combo), ["pointer", "pointer"])
check("combo: keys pass through, the touchpad is the pointer", roles({"role": "combo"}, combo),
["passthrough", "pointer"])
check("pointer saved: every node", roles({"role": "pointer"}, combo), ["pointer", "pointer"])
check("passthrough saved: every node", roles({"role": "passthrough"}, combo), ["passthrough", "passthrough"])
check("ignore saved: every node", roles({"role": "ignore"}, combo), ["ignore", "ignore"])
check("an unknown role: the default", roles({"role": "bogus"}, combo), ["pointer", "pointer"])
# The Swiftpoint Z3: its keyboard node is its extra buttons, so it stays with its mouse by default.
z3 = [node("Z3 Mouse", "aa:bb:cc:dd:ee:ff", True, False), node("Z3 Keyboard", "aa:bb:cc:dd:ee:ff", False, True)]
check("Z3, no rule: both nodes pointer", roles({}, z3), ["pointer", "pointer"])
kb = [node("TK-KB006 Keyboard", "11:22:33:44:55:66", False, True)]
check("a plain keyboard: pass through", roles({}, kb), ["passthrough"])
check("combo on a plain keyboard: still pass through", roles({"role": "combo"}, kb), ["passthrough"])
mouse = [node("Some Mouse", "77:88:99:aa:bb:cc", True, False)]
check("combo on a plain mouse: still the pointer", roles({"role": "combo"}, mouse), ["pointer"])
both = [node("One Node Combo", "dd:ee:ff:00:11:22", True, True)]
check("combo on one node with keys and motion: the pointer (keys go through its button map)",
roles({"role": "combo"}, both), ["pointer"])
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
sys.exit(1 if failures else 0)
+4 -1
View File
@@ -117,14 +117,17 @@ step "6/12 power service (turns the displays off while the headset isn't used, e
build "$root/power/build.sh"
"$root/power/run.sh" install
step "7/12 multi-screen desktop (ft-screens), Frametop Input Settings, Display Settings, and Remote Displays"
step "7/12 multi-screen desktop (ft-screens), its keyboard (Full Keyboard), Frametop Input Settings, Display Settings, Remote Displays, and the Tour"
build "$root/screens/build.sh"
build "$root/stream/build.sh" # ft-stream: remote displays (Frametop Remote Displays)
build "$root/keyboard/build.sh" # Frametop's keyboard: Full Keyboard, by TaiKeid (keyboard/FRAMETOP.md)
"$root/desktops.sh" install >/dev/null
"$root/input-settings/install.sh"
"$root/display-settings/install.sh"
"$root/tour/install.sh"
"$root/remote-displays/install.sh"
"$root/remote/install.sh"
"$root/keyboard/run.sh" install
on_frame "sed -i 's/^POINTER=0/POINTER=1/' ~/.config/frametop.conf; grep -q '^POINTER=' ~/.config/frametop.conf || echo 'POINTER=1' >> ~/.config/frametop.conf"
echo "the launcher's Desktop entry now opens the multi-screen desktop; 3D mouse on (POINTER=1 in ~/.config/frametop.conf)"
"$root/scripts/conf-migrate.sh"
+2 -2
View File
@@ -14,13 +14,13 @@ test: test-python test-c test-bash
# python3 has to import the dnf Qt stack and the locked packages together;
# without that check the Qt tests would only skip. session/test is not here:
# test_accessibility.py needs the host's AT-SPI and PyGObject, test_notifyfwd.py
# the host's dbus-daemon and PyGObject.
# and test_gtksettings.py the host's dbus-daemon and PyGObject.
test-python:
#!/usr/bin/env bash
set -uo pipefail
python3 -c 'import PySide6, numpy, cv2, sherpa_onnx' || { echo "python3 can't import PySide6, numpy, cv2, and sherpa_onnx"; exit 1; }
status=0
for t in models/test/*.py input/test/*.py hands/tests/test_*.py hands/rec/tests/*.py recorder/tests/*.py gaze/test/*.py gaze/rec/tests/*.py pack/test/*.py voice/test/*.py spotwatch/test/*.py; do
for t in models/test/*.py input/test/*.py hands/tests/test_*.py hands/rec/tests/*.py recorder/tests/*.py gaze/test/*.py gaze/rec/tests/*.py pack/test/*.py voice/test/*.py spotwatch/test/*.py tour/test/*.py; do
[ -f "$t" ] || continue
if python3 "$t" >/dev/null; then echo "$t: ok"; else echo "$t: FAILED"; status=1; fi
done
+11
View File
@@ -0,0 +1,11 @@
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 105
NamespaceIndentation: None
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
AllowShortFunctionsOnASingleLine: InlineOnly
BreakBeforeBraces: Attach
PointerAlignment: Left
SortIncludes: CaseSensitive
InsertBraces: true
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{json,md,yml}]
indent_size = 2
+2
View File
@@ -0,0 +1,2 @@
# Preserve vendored SDK formatting; enforce whitespace checks on our own sources.
third_party/** -whitespace
+30
View File
@@ -0,0 +1,30 @@
---
name: Bug report
about: Report a problem with Full Keyboard on Steam Frame
title: ''
labels: ''
assignees: ''
---
## What happened?
Describe the expected and actual behavior.
## Steps to reproduce
1.
2.
3.
## Setup
- Full Keyboard version:
- SteamOS / runtime version:
- App and version:
- Desktop window or floating app:
- Keyboard layout, language, and theme:
- Controller model:
## Screenshot or log, if useful
Remove passwords, private text, clipboard contents, and account details before posting.
+7
View File
@@ -0,0 +1,7 @@
## Change
What problem does this solve, and what happens after the change?
## Validation
List relevant tests and say whether this was checked in the headset or only with automated tests. Note untested behavior.
+12
View File
@@ -0,0 +1,12 @@
/build/
/out/
/dist/
/.cache/
__pycache__/
*.pyc
CMakeUserPresets.json
compile_commands.json
.env
.env.*
/docs/**/*.drawio
/_bugs.md
+20
View File
@@ -0,0 +1,20 @@
# Full Keyboard for Steam Frame development
Follow any enclosing workspace instructions. This file covers project engineering; keep machine-specific notes, deployment records, and credentials outside this repository. The public project name is Full Keyboard for Steam Frame, the app label is Full Keyboard, and internal identifiers remain framekeyboard.
- Read `README.md`, `docs/plan.md`, and the relevant architecture section before implementation. Mark milestone checkboxes only after their acceptance conditions pass.
- The approved appearance is `design/index.html`. Native key geometry is `layouts/en-us.json`; visual constants are `themes/graphite.json`. Keep fixed-size press movement, the flat case, shallow key sides, and left Copy/Paste keys.
- Cross-build deployment artifacts on the development host. Keep host and ARM64 output separate. Do not maintain a source checkout on Frame or run an ARM64 artifact on the host.
- Treat file-based layouts, languages and themes with in-VR selection as core requirements. Read `docs/configuration.md`; keep key geometry, input mapping and styles independent. Do not equate relabeled keys with working language support.
- Keep rendering, key state, input backend, and VR lifecycle separate. No actual input delivery from UI rendering code.
- Default development behavior must not send keys to the user's focused application. Test input with a dedicated receiver before application validation.
- Do not log typed text, credentials, clipboard contents, or browser field values. Do not add clipboard reads for debugging.
- Distinguish logical key IDs, Linux evdev codes, and Steam key codes. Never reuse raw numeric codes across backends.
- Every held key needs a release path on cancellation, target change, disconnect, hide, and shutdown. The stock keyboard must remain usable if the native process dies.
- Keep manual launching by default. Explicit Start with SteamOS requires replacement and starts hidden through a user-local session unit; previews must never configure autostart. In replacement mode, X dismisses the panel while the app remains resident; only Settings exposes Quit Full Keyboard. With replacement off, X exits as before. Optional native keyboard replacement is default-off, uses temporary feature-checked hooks, preserves Steam typing targets, and must restore stock independently when the app stops progressing. Close native presentation completely, including its scene geometry; do not use browser-window hiding to suppress it. Leave requests native when their original owner cannot be restored safely. Never patch Steam files or persist a global summon remap.
- Feature-check private runtime interfaces and fail without changing Steam files or global input settings.
- Vendor external code only with a pinned revision and its license/attribution. The sibling overlay is a reference, not an implicit build dependency.
- Git stays local until publication is requested. Do not copy build sysroots, credentials, machine logs, or recovery backups into Git.
- Use `.clang-format` for our C++ files. Keep functions focused and comment non-obvious state ownership, coordinate/code conversions and cleanup ordering. Do not reformat vendored code without reason.
- Run `ctest --test-dir build/host --output-on-failure` for core changes. Run the separate uinput smoke test only against its exclusively grabbed device; never turn it into a test of the user's focused app.
+73
View File
@@ -0,0 +1,73 @@
# Changelog
## 0.8.1: Update handoff fix
- Fixed in-app update shutdown reopening Steam's native keyboard. The old typing session and queued input now end before the updated keyboard starts, while Quit and failure recovery still restore native.
- Kept background replacement updates hidden until a fresh keyboard request, even if Start with SteamOS was disabled during the session. Manual updates and updates with replacement disabled still show the panel normally.
When updating from 0.7.0 or 0.8.0, Steam's native keyboard may still open once because the old version handles its own shutdown. Dismiss it; the next keyboard request can open Full Keyboard.
## 0.8.0: Typing reliability and Steam dashboard fixes
- Added an ordered output queue so rapid two-controller typing, Backspace and arrow keys keep their press order while delivery is pending. Hiding, dismissal, target changes and native handoff discard old queued input and ignore late results.
- Made local-app text delivery asynchronous so a slow compositor does not block the keyboard. Input connections recover in the background without replaying old keys or text. Bundled the fixed libei 1.6.0 client to prevent crashes during interrupted input negotiation, without changing system libraries.
- Fixed Steam search typing reaching a local app behind the dashboard. Text stays bound to the selected Steam field, follows switches between supported Steam and local-app targets, and stops when the field loses focus or disappears.
- Improved native keyboard handoff and cleanup when owners change, the helper fails, or Steam's UI stalls. Abandoned installation attempts are cleaned up, native restoration is checked, and repeated failures use bounded retries.
- Fixed the first keyboard request after background startup and SteamVR session restart, and restored replacement readiness after headset sleep or tracking loss without requiring an app relaunch.
- Fixed Escape dismissal in Steam fields and dashboard sessions. Releasing a queued Backspace repeat now completes its delivery without another deletion, preventing a missing acknowledgement from forcing native fallback.
- Removed the routine pending-text notice that flashed during typing. Reduced bridge polling and inspection work while idle, retaining independent recovery checks and bounded diagnostics that never include typed text.
- Preserved saved profile selections when startup temporarily falls back to another supported profile. Missing or incompatible profiles now produce a useful fallback reason, and custom key IDs no longer collide with toolbar controls.
- Fixed Japanese Romaji input depending on cosmetic key labels instead of resolved key symbols, and stopped idle Space from incorrectly starting composition.
- Made Reload wait for pending settings work while the VR loop stays responsive. Unrelated preference changes can still save when startup configuration fails, and storage errors remain visible.
- Manual reinstalls and downgrades now use fresh release directories. Replacement removes only the old marked production release after successful activation, or startup confirmation for in-app updates, while preserving settings and unmarked development releases.
- Refactored VR lifecycle coordination and expanded requirements-based tests for routing, queued cancellation, dismissal, native fallback, recovery and controller ownership.
## 0.7.0: Movement, native replacement and updates
- Added a movement line below the keyboard with native-style colors and hover feedback. Hold it with the controller laser or AUX head cursor to move the keyboard; the same controller's thumbstick adjusts distance.
- Renamed Appearance to General and added **Use Drag to Move** to select grip movement instead of the default laser line. Pinning blocks both movement methods and hides the line.
- Added **Auto leveling** in General, enabled by default, to level small sideways tilts after releasing movement. Aligned the General checkboxes with their labels on the right.
- Added experimental **Replace native keyboard** for supported dashboard, Steam-field and local-app requests. It is off by default and requires Steam Developer Mode with an available local debugger connection. Independent recovery restores the native keyboard when Full Keyboard becomes unavailable or stops progressing.
- In replacement mode, × dismisses the panel while the app stays ready for the next request. **Quit Full Keyboard** appears in Settings, and **Use Steam keyboard** returns the current request to Steam.
- Added optional **Start with SteamOS**, available only with replacement enabled. It starts the keyboard and recovery helpers in the background without showing the panel, ready for native keyboard requests. Startup after a device reboot has been manually verified.
- Added **Check for updates** at the top of Settings. Checks run only when requested, report the latest-version result briefly, and keep a newer release available to download and install until the app exits.
- Updates verify release checksums, archive contents, ARM64 architecture, version and profiles before installation. They restart the keyboard, preserve settings and startup preferences, and roll back if the new app fails to complete its startup checks. Failures show a short reason and retain normal logging.
- Improved service recovery across SteamVR restarts and update handoffs. Saved startup preferences and owned service files stay consistent on Apply, Reload and launch, including after upgrades. Deliberate Quit pauses service restart, while ordinary manual launches retain successful exit codes.
- Improved movement release detection, cancellation of native key repeats, and recovery from stalled or signal-stopped keyboard processes, with bounded allowances for slow work and SteamVR waits.
## 0.6.0: Settings and keyboard preferences
- Rebuilt Settings as separate cards for languages/layouts and appearance, with horizontal card scrolling and independent vertical scrolling within each card.
- Added Layout, Language, and Theme dropdowns with five visible choices and scrolling. Menus can extend beyond the keyboard case and dismiss when clicked outside.
- Moved Back, Apply and save, and Reload config into the Settings toolbar.
- Added a Favorite checkbox for layout/language pairs. When favorites exist, a main-toolbar language button cycles the saved default and unique favorites while retaining the active theme and saved default.
- Added a persistent pin button after the zoom controls to prevent accidental grip movement while retaining dashboard following, resizing, and recentering.
- Added global Num Lock persistence across restarts and profile changes, plus a Hide numpad preference that narrows the case while keeping the remaining key sizes.
- Fixed native keypad output disagreeing with restored Num Lock in physical uinput and external JIS modes. Digits and decimal wait for confirmed target lock state; keypad Enter and operators remain usable without lock feedback.
- Fixed toolbar buttons overlapping Close when hiding the numpad in custom layouts.
- Made favorite cycling faster and moved preference writes off the input thread, with ordered saves, shutdown flushing, and save-error reporting.
- Fixed fractional dropdown scrolling and preserved precise wheel input in the desktop preview.
- Added a British English layout and language profile, contributed by [Cr0ss0vr](https://github.com/cr0ss0vr).
## 0.5.0: First release
Full Keyboard for Steam Frame is a separately launched alternative keyboard for the Frame dashboard and local apps. It does not replace the stock keyboard or send input to streamed VR applications.
- Composition commits before ordinary spaces, digits and punctuation, with failed-delivery protection.
- Chinese/Korean engines load on demand; missing libraries leave the rest of the keyboard usable.
- Added French, Spanish, Italian, Brazilian ABNT2, Russian and Ukrainian profiles, Simplified/Traditional Pinyin candidates, and Korean two-set Hangul composition. Language settings and system-layout limitations are documented.
- Sticky left Super and momentary native right Super, with monochrome Frame and SteamOS icons.
- Full-size keyboard with F1–F12, arrow and navigation keys, numpad, Ctrl/Alt/Shift, and dedicated Copy/Paste buttons.
- Layout-independent Unicode character delivery, local Shift/Caps/AltGr and accents, plus native navigation, shortcuts and key repeat.
- Controller laser typing, per-controller press/release haptics, and subtle hover feedback.
- Grip movement and rotation, same-hand thumbstick depth adjustment, size controls, and horizon alignment after release.
- Dashboard position and rotation following, saved placement, and relaunch-to-recenter recovery.
- Dashboard-only visibility and input, with cancellation on hiding, tracking loss, or disconnect.
- Editable JSON layouts, languages, and themes, with in-VR selection, favorites, and profile reload.
- English and German profiles, plus Japanese romaji/kana composition, kanji candidates, and an external JIS IME mode.
- Graphite and Midnight themes, a native desktop preview, and image export.
- Incremental key redraws and native pixel uploads where supported, with an RGBA compatibility fallback and event-driven idle rendering.
- User-local ARM64 installation, preserved settings, versioned rollback, release checksums, and manual installation instructions.
- MIT-licensed project code, build documentation, and automated regression tests.
See the [README](README.md) for installation and controls, and [runtime documentation](docs/runtime.md) for language requirements and known limitations.
+270
View File
@@ -0,0 +1,270 @@
cmake_minimum_required(VERSION 3.24)
project(framekeyboard VERSION 0.8.1 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Keep build-machine directories out of __FILE__, assertions and debug paths.
# RelWithDebInfo remains useful locally; packaging strips only its installed copy.
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_compile_options("-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=."
"-ffile-prefix-map=${CMAKE_CURRENT_BINARY_DIR}=./build")
if(CMAKE_SYSROOT)
add_compile_options("-ffile-prefix-map=${CMAKE_SYSROOT}=/")
endif()
endif()
find_package(Python3 REQUIRED COMPONENTS Interpreter)
find_package(PkgConfig REQUIRED)
find_package(Threads REQUIRED)
pkg_check_modules(NATIVE REQUIRED IMPORTED_TARGET cairo pangocairo xkbcommon json-c)
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
# SteamOS libei 1.4.1 crashes on an aborted negotiation. Keep the fixed client
# inside this app; never replace the runtime library used by Steam itself.
add_subdirectory(third_party/libei)
find_program(WAYLAND_SCANNER wayland-scanner REQUIRED)
set(IME_XML "${CMAKE_CURRENT_SOURCE_DIR}/third_party/gamescope/gamescope-input-method.xml")
set(IME_HEADER "${CMAKE_CURRENT_BINARY_DIR}/generated/gamescope-input-method-client.h")
set(IME_CODE "${CMAKE_CURRENT_BINARY_DIR}/generated/gamescope-input-method-protocol.c")
add_custom_command(OUTPUT "${IME_HEADER}" "${IME_CODE}"
COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/generated"
COMMAND "${WAYLAND_SCANNER}" client-header "${IME_XML}" "${IME_HEADER}"
COMMAND "${WAYLAND_SCANNER}" private-code "${IME_XML}" "${IME_CODE}"
DEPENDS "${IME_XML}" VERBATIM)
pkg_check_modules(SDL REQUIRED IMPORTED_TARGET sdl2)
pkg_check_modules(VULKAN REQUIRED IMPORTED_TARGET vulkan)
file(GLOB LAYOUT_FILES CONFIGURE_DEPENDS layouts/*.json)
file(GLOB PROFILE_FILES CONFIGURE_DEPENDS layouts/*.json languages/*.json themes/*.json)
set(PROFILE_HEADER "${CMAKE_CURRENT_BINARY_DIR}/generated/bundled_profiles.hpp")
add_custom_command(OUTPUT "${PROFILE_HEADER}"
COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/validate_layout.py"
${LAYOUT_FILES}
COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/embed_defaults.py"
"${CMAKE_CURRENT_SOURCE_DIR}" "${PROFILE_HEADER}"
DEPENDS ${PROFILE_FILES} tools/embed_defaults.py tools/validate_layout.py
VERBATIM)
add_library(keyboard_core src/vr_lifecycle.cpp src/vr_runtime.cpp src/config.cpp src/input.cpp src/output.cpp src/panel.cpp src/settings_ui.cpp src/settings_writer.cpp src/startup.cpp src/app.cpp src/instance.cpp src/placement.cpp src/placement_store.cpp src/grip.cpp src/ei_input.cpp src/japanese.cpp src/cjk.cpp src/text_input.cpp src/native_replacement.cpp src/updates.cpp src/frametop.cpp "${IME_HEADER}" "${IME_CODE}" "${PROFILE_HEADER}")
target_include_directories(keyboard_core PUBLIC include PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(keyboard_core PUBLIC PkgConfig::NATIVE framekeyboard_ei PkgConfig::WAYLAND_CLIENT ${CMAKE_DL_LIBS} Threads::Threads)
target_compile_options(keyboard_core PRIVATE -Wall -Wextra -Wpedantic -Wconversion)
# Frame already supplies these engines. Minimal host builds can still run the
# ordinary keyboard; unsupported composition profiles fail explicitly on Apply.
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(PYZY QUIET IMPORTED_TARGET pyzy-1.0)
pkg_check_modules(HANGUL QUIET IMPORTED_TARGET libhangul)
foreach(engine PYZY HANGUL)
if(${engine}_FOUND)
if(engine STREQUAL "PYZY")
set(module pinyin)
else()
set(module hangul)
endif()
add_library(cjk_${module} MODULE src/cjk_engine.cpp)
target_link_libraries(cjk_${module} PRIVATE PkgConfig::GLIB PkgConfig::${engine})
target_compile_definitions(cjk_${module} PRIVATE FRAMEKEYBOARD_HAVE_${engine}=1)
target_compile_options(cjk_${module} PRIVATE -Wall -Wextra -Wpedantic -Wconversion)
if(CMAKE_CROSSCOMPILING)
target_link_options(cjk_${module} PRIVATE -fuse-ld=lld)
endif()
set_target_properties(cjk_${module} PROPERTIES
PREFIX "" OUTPUT_NAME "framekeyboard-${module}"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/engines"
CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN YES)
add_dependencies(keyboard_core cjk_${module})
install(TARGETS cjk_${module} LIBRARY DESTINATION lib/framekeyboard)
elseif(CMAKE_CROSSCOMPILING)
message(FATAL_ERROR "Frame releases require ${engine} headers and libraries in the sysroot")
else()
message(STATUS "${engine} unavailable: this host build cannot compose that language")
endif()
endforeach()
# Link the matching architecture from the sysroot; embed only the device runtime path.
if(CMAKE_CROSSCOMPILING)
set(OPENVR_LIBRARY "${CMAKE_SYSROOT}/opt/steamvr/bin/linuxarm64/libopenvr_api.so" CACHE FILEPATH "OpenVR library")
set(OPENVR_RUNTIME_PATH "/opt/steamvr/bin/linuxarm64")
else()
find_library(OPENVR_LIBRARY openvr_api HINTS
"$ENV{HOME}/.local/share/Steam/steamapps/common/SteamVR/bin/linux64"
"$ENV{HOME}/.local/share/Steam/ubuntu12_64" REQUIRED)
# Frametop's build names SteamVR's folder first (keyboard/build.sh).
if(NOT OPENVR_RUNTIME_PATH)
get_filename_component(OPENVR_RUNTIME_PATH "${OPENVR_LIBRARY}" DIRECTORY)
endif()
endif()
add_library(overlay_texture third_party/overlay_texture/vk_texture.cpp)
target_include_directories(overlay_texture PUBLIC third_party/overlay_texture third_party/openvr)
target_link_libraries(overlay_texture PUBLIC PkgConfig::VULKAN "${OPENVR_LIBRARY}")
add_executable(framekeyboard src/main.cpp src/preview.cpp src/vr.cpp)
add_executable(framekeyboard-native-guard src/native_restore_guard.cpp)
target_include_directories(framekeyboard-native-guard PRIVATE third_party/openvr)
target_link_libraries(framekeyboard-native-guard PRIVATE PkgConfig::NATIVE "${OPENVR_LIBRARY}")
set_target_properties(framekeyboard-native-guard PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "${OPENVR_RUNTIME_PATH}")
add_dependencies(framekeyboard framekeyboard-native-guard)
target_link_libraries(framekeyboard PRIVATE keyboard_core overlay_texture PkgConfig::SDL)
target_compile_options(framekeyboard PRIVATE -Wall -Wextra -Wpedantic -Wconversion)
target_compile_definitions(framekeyboard PRIVATE FRAMEKEYBOARD_VERSION="${PROJECT_VERSION}")
# Development binaries find these beside themselves; packages use share/framekeyboard.
# Do not embed an absolute source-tree path in the executable for this fallback.
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/vr")
foreach(manifest actions.json frame-controller.json)
configure_file("vr/${manifest}" "vr/${manifest}" COPYONLY)
endforeach()
set_target_properties(framekeyboard PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "${OPENVR_RUNTIME_PATH}")
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/bridge")
foreach(bridge_file native_bridge.py steam_bridge.js)
configure_file("bridge/${bridge_file}" "bridge/${bridge_file}" COPYONLY)
endforeach()
file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/update")
configure_file(update/update_helper.py update/update_helper.py COPYONLY)
include(CTest)
if(BUILD_TESTING)
add_executable(vr-lifecycle-tests tests/vr_lifecycle_tests.cpp)
target_link_libraries(vr-lifecycle-tests PRIVATE keyboard_core)
target_compile_options(vr-lifecycle-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME vr-lifecycle COMMAND vr-lifecycle-tests)
add_executable(ei-handshake-tests tests/ei_handshake_tests.cpp)
target_link_libraries(ei-handshake-tests PRIVATE framekeyboard_ei)
add_test(NAME ei-handshake COMMAND ei-handshake-tests)
add_executable(output-tests tests/output_tests.cpp)
target_link_libraries(output-tests PRIVATE keyboard_core)
add_test(NAME ordered-output COMMAND output-tests)
add_executable(startup-tests tests/startup_tests.cpp)
target_link_libraries(startup-tests PRIVATE keyboard_core)
add_test(NAME startup COMMAND startup-tests)
find_program(SYSTEMD_ANALYZE systemd-analyze)
if(SYSTEMD_ANALYZE AND NOT CMAKE_CROSSCOMPILING)
add_test(NAME startup-unit COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/startup_unit_tests.py"
"$<TARGET_FILE:startup-tests>" "${SYSTEMD_ANALYZE}")
endif()
add_executable(update-tests tests/update_tests.cpp)
target_link_libraries(update-tests PRIVATE keyboard_core)
add_test(NAME updates COMMAND update-tests)
if(NOT CMAKE_CROSSCOMPILING)
add_test(NAME update-helper COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/update_helper_tests.py"
"${CMAKE_CURRENT_SOURCE_DIR}/update/update_helper.py")
endif()
add_executable(frametop-tests tests/frametop_tests.cpp)
target_link_libraries(frametop-tests PRIVATE keyboard_core)
add_test(NAME frametop COMMAND frametop-tests)
add_executable(native-replacement-tests tests/native_replacement_tests.cpp)
target_link_libraries(native-replacement-tests PRIVATE keyboard_core)
add_test(NAME native-replacement COMMAND native-replacement-tests)
if(NOT CMAKE_CROSSCOMPILING)
add_test(NAME timing-ordering COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/timing_order_tests.py"
"${CMAKE_CURRENT_SOURCE_DIR}")
add_test(NAME native-helper-spawn COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/native_helper_spawn_tests.py"
"$<TARGET_FILE:native-replacement-tests>"
"${CMAKE_CURRENT_SOURCE_DIR}/bridge/native_bridge.py")
add_test(NAME native-bridge-transport COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/native_bridge_transport_tests.py"
"${CMAKE_CURRENT_SOURCE_DIR}/bridge/native_bridge.py")
add_test(NAME replacement-measurement COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/replacement_measurement_tests.py"
"${CMAKE_CURRENT_SOURCE_DIR}/scripts/measure-replacement.py")
find_program(NODE_EXECUTABLE node)
if(NODE_EXECUTABLE)
add_test(NAME native-bridge COMMAND "${NODE_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/native_bridge_tests.js"
"${CMAKE_CURRENT_SOURCE_DIR}/bridge/steam_bridge.js")
endif()
endif()
# Opt-in timing probe: never sends input; --vr uses its own hidden overlay.
add_executable(render-benchmark tests/render_benchmark.cpp)
target_link_libraries(render-benchmark PRIVATE keyboard_core overlay_texture Threads::Threads)
target_compile_options(render-benchmark PRIVATE -Wall -Wextra -Wpedantic)
set_target_properties(render-benchmark PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "${OPENVR_RUNTIME_PATH}")
add_executable(language-tests tests/language_tests.cpp)
target_link_libraries(language-tests PRIVATE keyboard_core)
target_compile_options(language-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME languages COMMAND language-tests --core)
add_test(NAME korean-input COMMAND language-tests --korean)
add_test(NAME chinese-input COMMAND language-tests --chinese)
set_tests_properties(korean-input chinese-input PROPERTIES SKIP_RETURN_CODE 77)
if(NOT CMAKE_CROSSCOMPILING)
add_test(NAME optional-engines COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/engine_loading_tests.py" "$<TARGET_FILE:language-tests>")
endif()
add_executable(japanese-tests tests/japanese_tests.cpp)
target_link_libraries(japanese-tests PRIVATE keyboard_core)
add_test(NAME japanese-input COMMAND japanese-tests)
add_executable(settings-tests tests/settings_tests.cpp)
target_link_libraries(settings-tests PRIVATE keyboard_core)
target_compile_options(settings-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME settings COMMAND settings-tests)
add_executable(keyboard-tests tests/keyboard_tests.cpp)
add_executable(render-tests tests/render_tests.cpp)
target_link_libraries(render-tests PRIVATE keyboard_core)
target_compile_options(render-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME rendering COMMAND render-tests)
target_link_libraries(keyboard-tests PRIVATE keyboard_core)
target_compile_options(keyboard-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME keyboard-core COMMAND keyboard-tests)
add_executable(placement-instance-tests tests/placement_instance_tests.cpp)
target_link_libraries(placement-instance-tests PRIVATE keyboard_core Threads::Threads)
target_compile_options(placement-instance-tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME placement-instance COMMAND placement-instance-tests)
if(NOT CMAKE_CROSSCOMPILING)
add_test(NAME installer COMMAND "${Python3_EXECUTABLE}"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/installer_tests.py"
"${CMAKE_CURRENT_SOURCE_DIR}" "$<TARGET_FILE:framekeyboard>")
pkg_check_modules(EIS QUIET IMPORTED_TARGET libeis-1.0>=1.6)
if(EIS_FOUND)
add_executable(ei-recovery-tests tests/ei_recovery_tests.cpp)
target_link_libraries(ei-recovery-tests PRIVATE keyboard_core PkgConfig::EIS Threads::Threads)
pkg_check_modules(WAYLAND_SERVER QUIET IMPORTED_TARGET wayland-server)
if(WAYLAND_SERVER_FOUND)
set(IME_SERVER_HEADER "${CMAKE_CURRENT_BINARY_DIR}/generated/gamescope-input-method-server.h")
add_custom_command(OUTPUT "${IME_SERVER_HEADER}"
COMMAND "${WAYLAND_SCANNER}" server-header "${IME_XML}" "${IME_SERVER_HEADER}"
DEPENDS "${IME_XML}" VERBATIM)
target_sources(ei-recovery-tests PRIVATE "${IME_SERVER_HEADER}")
target_include_directories(ei-recovery-tests PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/generated")
target_link_libraries(ei-recovery-tests PRIVATE PkgConfig::WAYLAND_SERVER)
target_compile_definitions(ei-recovery-tests PRIVATE FRAMEKEYBOARD_TEST_WAYLAND_SERVER=1)
endif()
add_test(NAME ei-recovery COMMAND ei-recovery-tests)
endif()
endif()
# Opt-in only: maps a disposable window and verifies focus before each key.
pkg_check_modules(X11 REQUIRED IMPORTED_TARGET x11)
add_executable(unicode-target-probe tests/unicode_target_probe.cpp)
target_link_libraries(unicode-target-probe PRIVATE keyboard_core PkgConfig::X11)
add_executable(japanese-target-probe tests/japanese_target_probe.cpp)
target_link_libraries(japanese-target-probe PRIVATE keyboard_core PkgConfig::X11)
add_executable(ei-target-probe tests/ei_target_probe.cpp)
target_link_libraries(ei-target-probe PRIVATE keyboard_core PkgConfig::X11)
# Opt-in read-only validation against the installed Frame controller models.
add_executable(stick-component-probe tests/stick_component_probe.cpp)
target_link_libraries(stick-component-probe PRIVATE keyboard_core overlay_texture)
set_target_properties(stick-component-probe PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "${OPENVR_RUNTIME_PATH}")
add_executable(vr-panel-probe tests/vr_panel_probe.cpp)
target_link_libraries(vr-panel-probe PRIVATE keyboard_core overlay_texture)
set_target_properties(vr-panel-probe PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "${OPENVR_RUNTIME_PATH}")
# Opt-in: sends physical haptic output, so it must not run in CTest.
add_executable(haptics-probe tests/haptics_probe.cpp)
target_link_libraries(haptics-probe PRIVATE keyboard_core overlay_texture)
set_target_properties(haptics-probe PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "${OPENVR_RUNTIME_PATH}")
# Intentionally excluded from CTest: requires an accessible kernel uinput device.
add_executable(uinput-smoke-test tests/uinput_smoke.cpp)
target_link_libraries(uinput-smoke-test PRIVATE keyboard_core)
endif()
install(TARGETS framekeyboard framekeyboard-native-guard RUNTIME DESTINATION bin)
install(DIRECTORY docs DESTINATION .)
install(DIRECTORY layouts languages themes config vr DESTINATION share/framekeyboard)
install(FILES bridge/native_bridge.py bridge/steam_bridge.js DESTINATION share/framekeyboard/bridge)
install(FILES update/update_helper.py DESTINATION share/framekeyboard/update)
install(FILES third_party/README.md DESTINATION third_party)
install(FILES third_party/openvr/LICENSE DESTINATION third_party/openvr)
install(FILES third_party/overlay_texture/LICENSE DESTINATION third_party/overlay_texture)
install(FILES third_party/libei/COPYING third_party/libei/README.md third_party/libei/provenance.json DESTINATION third_party/libei)
install(FILES third_party/gamescope/gamescope-input-method.xml DESTINATION third_party/gamescope)
install(FILES LICENSE README.md CHANGELOG.md CONTRIBUTING.md DESTINATION .)
+25
View File
@@ -0,0 +1,25 @@
{
"version": 5,
"cmakeMinimumRequired": {"major": 3, "minor": 24, "patch": 0},
"configurePresets": [
{
"name": "host",
"displayName": "Host development build",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/host",
"cacheVariables": {"CMAKE_BUILD_TYPE": "Debug"}
},
{
"name": "frame-arm64",
"displayName": "Steam Frame ARM64 cross-build",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/frame-arm64",
"toolchainFile": "${sourceDir}/cmake/steam-frame-aarch64.cmake",
"cacheVariables": {"CMAKE_BUILD_TYPE": "RelWithDebInfo"}
}
],
"buildPresets": [
{"name": "host", "configurePreset": "host"},
{"name": "frame-arm64", "configurePreset": "frame-arm64"}
]
}
+47
View File
@@ -0,0 +1,47 @@
# Contributing
Full Keyboard for Steam Frame welcomes bug reports, review, and focused fixes. The app serves the Frame dashboard and local applications. Optional native keyboard replacement is experimental and must preserve native fallback when the app is unavailable. Typing into streamed VR apps is outside the current scope.
## Report a bug
Include the app version, SteamOS/runtime version, controller model, affected app, desktop versus floating-window mode, selected language/layout, reproduction steps, and expected/actual result. Include a screenshot or a short log only if it helps. Remove passwords, private text, account details, and clipboard contents.
For Japanese input, include a harmless example reading, the expected conversion, and the selected Romaji/Kana/JIS mode. Feedback from fluent Japanese users is especially useful.
## Dependencies and third-party code
Do not bundle whole third-party projects inside this repository, including through submodules or source snapshots. Keep external projects separate and use installed libraries, tools, or services through their documented interfaces. Normal builds and installation must not implicitly download external projects or large runtime assets.
Occasional reference files, headers, protocol definitions, and small helpers are fine when they serve a clear purpose. Record their upstream source, exact revision, local modifications, and applicable licenses and attribution. Check embedded components and transitive dependencies; a project's root license may not cover every file. Preserve the required notices in source and release packages.
Document external dependencies and any separate setup they require. Keep optional dependencies optional during build, installation, and startup. Do not enable autostart, start external services, or change system configuration implicitly.
## Optional features and capabilities
Optional features must leave ordinary keyboard operation usable when a dependency is absent, loading, incompatible, or disconnected. Verify readiness and capabilities through the dependency's interface rather than assuming that an installed file or running process is sufficient. Keep integration-specific code behind a defined interface so the UI and profile data do not depend on a particular implementation.
External interfaces must have a documented version and capability contract. Report capabilities for the active configuration, including supported languages, formats, and operations where relevant. Define compatibility and locale matching explicitly. Validate requests against current capabilities, handle capability changes, and reject unsupported operations without silently changing the requested behavior.
Express capability-dependent controls declaratively in profile data. Account for the selected layout, language, and input backend. Use a small validated set of conditions; do not execute scripts or arbitrary expressions from profile files. Hidden controls must be absent from both rendering and hit testing. Keep cancellation controls accessible during active operations, and apply visibility changes without stranding held input or moving another action under a pressed pointer.
These are contribution requirements, not a claim that every interface or profile condition already exists. Include parsing, validation, documentation, and tests when introducing a new contract or schema field.
## Version ownership
Only the project owner, TaiKeid, may bump the project version. Contributors must not change the version in `CMakeLists.txt` or update release-version references as part of their contributions. The owner handles these changes during release preparation.
## Change the code
Read [building](docs/building.md), [architecture](docs/architecture.md), and [configuration](docs/configuration.md). Keep renderer, key state, backend, and profile data separate. Use `.clang-format` for project C++; preserve vendored code's style and attribution.
Run the core test suites for behavior changes. Test input with fake sinks or a dedicated receiver, never an arbitrary focused app. Cover lost releases, tracking loss, disconnects, hiding, and shutdown when changing interaction ownership. Add comments where state lifetime, coordinate transforms, or event ordering are not obvious.
Submit focused PRs and describe the behavior, validation, and remaining limits. Separate unrelated fixes, features, UI changes, and refactoring when they can be reviewed independently. Do not include sysroots, builds, device logs, personal paths, credentials, or user settings. Do not mix publication changes into unrelated PRs.
Every owned resource and subprocess needs a cleanup path for cancellation, hide, disconnect, and shutdown. Invalidate late asynchronous results and guard delayed input against destination changes. Do not terminate shared external services. Bound buffers and waits, and keep blocking work off the VR loop. Deliver text through the existing Unicode transport and retain unsent text when delivery fails. Do not log or persist user input.
Verify installation and packaging from a clean checkout without relying on untracked files or private local setup. For affected paths, cover missing dependencies, capability changes, unsupported configurations, resource cleanup, stale results, visibility and hit testing, non-US text delivery, and failed commits. State whether validation ran on the host, an ARM64 cross-build, the Frame, or in the headset. An ARM64 build alone does not verify runtime behavior on the device.
## Review priorities
Input cancellation, focus routing, two-controller ownership, placement persistence, resource cleanup, parsing of custom profiles, and runtime compatibility deserve close review. Tests and successful builds do not replace in-headset validation; say which one was performed.
+48
View File
@@ -0,0 +1,48 @@
# Full Keyboard in Frametop
This folder is [Full Keyboard for Steam Frame](https://github.com/TaiKeid/steam-frame-full-keyboard) by TaiKeid and contributors, under the MIT License ([LICENSE](LICENSE), Copyright (c) 2026 TaiKeid and contributors). Its own third-party parts and their licences are in [third_party/README.md](third_party/README.md). Frametop runs it as its keyboard: on the desktop's screens, in the SteamVR dashboard, and in place of Steam's own keyboard.
It was imported at 0.8.1, commit `ef915ef75169ac886b7de86d18df2e59150cde2f`, with `git subtree --squash`; the squash commit carries TaiKeid as its author. Everything here is theirs except the changes listed below.
## Frametop's changes
- `--frametop` (`src/main.cpp`): the VR keyboard, typing through gamescope (libei) as before, started enabled and hidden. Its settings live in `~/.config/frametop-keyboard`. It has no updater and no startup unit: Frametop's service starts it with SteamVR, and it updates with Frametop. The first run turns the Steam keyboard replacement on. Quitting it from its panel exits with status 3, so the service leaves it closed until SteamVR starts again.
- `src/frametop.cpp`, `include/framekeyboard/frametop.hpp`: the link to Frametop's input relay (below), and a key sink that sends typing to the desktop or to Steam.
- `src/vr.cpp`: the panel can show over Frametop's screens with the dashboard closed, while Frametop asked for it; lasers can click it there; it tells Frametop whether it's up.
- `src/placement.cpp`, `include/framekeyboard/placement.hpp`: in Frametop's mode it opens where Frametop's own keyboard did, 0.70 m ahead and 0.35 m under the eyes, facing them, instead of 0.85 m ahead and 0.65 m under, so a hand's laser reaches its bottom row without lowering the hand past where Frametop puts the laser away. Under the dashboard it sits as before.
- `src/app.cpp`: no Updates button and no Start with SteamOS setting in Frametop's mode.
- `src/japanese.cpp`, `tests/japanese_tests.cpp`: Anthy also loads as `libanthy-unicode.so.0`, Fedora's name for it.
- `CMakeLists.txt`: the above, `frametop-tests`, and `OPENVR_RUNTIME_PATH` can be set, so the build loads SteamVR's own `libopenvr_api` first like Frametop's other programs.
- Frametop's own files: `build.sh`, `run.sh`, `frametop-keyboard.service`, and this file.
## How it works with Frametop
Frametop's desktop runs in a nested KWin that gamescope never sees, so keys sent to gamescope can't reach it. The input relay (`input/input-relay.py`) owns typing for the desktop already: it knows where typing goes (the panel clicked last, from ft-screens), and it types Frametop Voice's text there. Full Keyboard talks to it over the relay's abstract datagram socket, `@frametop_relay`:
- Full Keyboard says `kbhello OPEN` every second, and at once when it opens or closes. The relay answers each hello, and any change, with `kb DESKTOP SURFACE`: whether typing goes to the desktop, and whether the screens show with Frametop not paused (then it may show over them with the dashboard closed).
- While typing goes to the desktop, its keys go to the relay as `kbkey CODE 0|1` and composed text as `kbtext TEXT`. The relay types them on the desktop's seat through ft-screens, in order with dictation's typing; the text goes through the desktop's keyboard layout and isn't logged. Otherwise they go to gamescope as before. A key comes up where it went down, whichever way typing goes by then, and when Full Keyboard stops, the relay lets go of the keys it held.
- Opening it: the relay tells ft-screens it runs (`fullkb OPEN`), and ft-screens then passes on its `vrkeyboard` commands (a desktop text field gets focus, a mapped button, spotwatch, a layout reset) as `kbpanel show|hide`, which the relay sends Full Keyboard as `kb show|hide`. Frametop's older panel (`screens/keyboard.cpp`) is used only while Full Keyboard isn't running.
- A click on its panel (`org.framekeyboard.*` overlays) doesn't take typing away from the desktop.
- Its Steam keyboard replacement (`bridge/`) is theirs, unchanged.
Only same-user messages count on either side. Full Keyboard takes word only from the relay's own name, and sends desktop typing only while that word is under 3 seconds old, so a stopped relay never leaves typing addressed to a name another process could take. The relay takes keys and text only from the address that said hello.
Known limits: on the desktop, keys go by their place on the keyboard, so pick the same layout in Full Keyboard as the desktop's. Text the desktop's layout has no key for (Chinese, Japanese, Korean, emoji) can't be typed on the desktop yet; it reaches Steam's fields as before.
## Building and running
```
keyboard/build.sh # build/frametop/framekeyboard, in the dev container
FULL_KEYBOARD_TESTS=1 keyboard/build.sh # with its tests (ctest)
keyboard/run.sh install | uninstall # frametop-keyboard.service, starts with SteamVR
keyboard/run.sh status | restart | log [lines]
input/test/keys-test.py # the relay's side, offline
```
## Updating from upstream
```
git subtree pull --prefix keyboard https://github.com/TaiKeid/steam-frame-full-keyboard <tag> --squash
```
Then build with its tests, run `input/test/keys-test.py`, and update the version and commit above.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 TaiKeid and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+137
View File
@@ -0,0 +1,137 @@
# Full Keyboard for Steam Frame
A full-size alternative to Steam Frame's built-in virtual keyboard, made for everyday use on the headset. It adds proper arrow keys, a numpad, Ctrl and Alt, and dedicated Copy and Paste buttons for browsing, signing in, editing text, and using local apps.
**Full Keyboard is launched manually by default.** By default, SteamOS's keyboard button opens the original keyboard. An experimental **Replace native keyboard** option in Experimental redirects supported requests while Full Keyboard is running. It requires Steam Developer Mode and falls back to the original keyboard when unavailable. Enable **Start with SteamOS** in the same card to start replacement in the background at login. It stays hidden until summoned. See [native replacement](docs/native-replacement.md).
![Full Keyboard with the Graphite theme](docs/images/keyboard-graphite.png)
*The default English layout and Graphite theme, captured from the app's native renderer.*
## What it does
- Full-size layout with F1–F12, navigation keys, arrow keys, numpad, Ctrl, Alt, and Shift.
- Dedicated Copy and Paste icons that send Ctrl+C and Ctrl+V.
- Native Enter and numpad Enter keys. The focused app decides whether Enter submits or inserts a newline.
- Controller laser typing, press/release haptic clicks, and subtle hover feedback.
- Move with the laser handle below the keyboard, or enable grab movement in Settings. Size controls are available in both modes.
- Dashboard following, saved placement, and relaunch-to-recenter recovery.
- Editable layouts, languages, and themes, with a settings menu inside VR.
- English, German, French, Spanish, Italian, Brazilian Portuguese, Russian, Ukrainian, Japanese, Chinese and Korean options. Chinese has Simplified and Traditional Pinyin; Korean uses two-set Hangul.
## Where it works
Full Keyboard is a quality-of-life tool for the **on-device experience**. It is designed for the Frame dashboard and local apps, including desktop and floating app windows. Focus a text field in the app, then type using the controller laser.
It is **not a keyboard for streamed VR applications such as VRChat**. Closing the dashboard hides the keyboard and releases its input. It does not capture controls or send keys to a streamed VR app. Reopening the dashboard brings it back.
This is an early release. App focus, non-US keymaps, and Japanese input can behave differently across applications; see [known limitations and troubleshooting](docs/runtime.md). Japanese composition has automated and dedicated-receiver coverage, but still needs feedback from fluent Japanese users.
## Install manually
No AI tools, compilation, `sudo`, or SteamOS write access are needed for the release package.
1. On the **Steam Frame**, open the [GitHub Releases page](https://github.com/TaiKeid/steam-frame-full-keyboard/releases) in a browser. Download `framekeyboard-0.8.1-aarch64.tar.gz` and `SHA256SUMS` from the release assets into **Downloads**. Do not download GitHub's automatic source-code archive for this installation.
2. Open a terminal in the Frame's desktop environment and run:
```sh
cd ~/Downloads
sha256sum -c SHA256SUMS
tar -xzf framekeyboard-0.8.1-aarch64.tar.gz
cd framekeyboard-0.8.1-aarch64
bash install.sh
```
Continue only if the checksum reports `OK`. For a newer version, use its archive and extracted directory names.
3. Return to the Frame's dashboard and open **Full Keyboard** from the app launcher. You can also launch it from a terminal:
```sh
~/.local/bin/framekeyboard
```
The installer adds a desktop application entry named **Full Keyboard**. If you also want a Steam Library shortcut, use Steam's **Add a Non-Steam Game** option and select Full Keyboard. A manually created shortcut has its own name; rename an old FrameKeyboard shortcut in its Properties if needed.
Everything installs under your home directory. The installer preserves settings and custom profiles, replaces the current production release, and does not enable autostart or alter Steam files. Development deployments and older unmarked release directories are preserved. Internal filenames and commands remain `framekeyboard`.
To update from the installed keyboard, open Settings and press **Check for updates** at the top center. If a newer stable release is available, press **Download and install update**. The keyboard verifies the archive, installs it and restarts, preserving your settings. The old production release stays until the new one confirms a healthy start, then is removed. A failed start rolls back automatically. See [updates](docs/updates.md).
For a manual update, quit Full Keyboard first. With native replacement enabled, use Settings → Quit Full Keyboard. Download the new release assets and repeat the installation steps. The installer supports reinstalling the same version or installing an older one, and refuses while Full Keyboard is running. Manual rollback means reinstalling an older release package; see [rollback and removal](docs/runtime.md#update-rollback-and-removal).
## Use the keyboard
| Action | Control |
| --- | --- |
| Type | Point at a key and press the controller trigger |
| Use a shortcut | Tap Ctrl, Alt, or Shift, then another key; or hold the modifier with one controller and press a key with the other |
| Super shortcuts | Tap the left Frame icon, then a key, or hold it with the other controller |
| Desktop application menu | Tap the right SteamOS icon for a native right Super press/release; the desktop controls its action |
| Clear a tapped modifier | Tap it again |
| Copy / paste | Use the two icons on the left; select text first when copying |
| Move / rotate | Unpin, point at the line below the keyboard, hold the trigger, move your hand, then release |
| Use the headset cursor | Press AUX to show the head cursor. Aim at keys with your head and click AUX; hold AUX on the movement line to move the keyboard |
| Use grab movement | Enable **Use Drag to Move** in the **General** settings card and apply. Point anywhere on the keyboard and hold grab/grip; the same hand's thumbstick adjusts depth |
| Prevent accidental movement | Toggle the pin icon; the keyboard still follows the dashboard |
| Cycle favorite layouts and languages | Press the language abbreviation beside the pin icon |
| Move farther / closer | While holding the movement line or grabbing, push that same controller's thumbstick up / down |
| Change size | Use the smaller / larger icons in the toolbar |
| Hide the numpad | In Settings, check General → Hide numpad and Apply and save |
| Change layout, language, or theme | Open the gear icon, choose profiles, then **Apply and save** |
| Recenter | Use the recenter icon, or launch Full Keyboard again |
| Close | Use × to hide the panel in replacement mode; otherwise it exits the app |
| Quit replacement mode | Open Settings → Quit Full Keyboard |
**If the keyboard has drifted out of reach, launch the app again.** When it is already running, another launch recenters the existing keyboard beneath the current dashboard instead of opening a second one. Its size is preserved. If the dashboard is closed, recentering waits until it opens.
Closing and reopening normally restores the last position, rotation, size and Num Lock state. Numpad visibility is saved globally across all layouts. Moving or tilting the dashboard carries the keyboard with it, preserving your chosen offset. With General → Auto leveling enabled, a sideways lean within 5° eases level over 500 ms after releasing movement. Auto leveling is on by default; disable it to keep the angle you set. This preserves the desk-like forward tilt and never fights your hand during a grab.
## Languages and customization
Select a language in Settings and **Apply and save**. The normal Frame launcher
sends characters from that layout independently of the system keyboard layout,
including Shift, Caps Lock, AltGr and dead-key accents. No matching SteamOS layout
or relaunch is needed. A fresh install defaults to English; later launches use
your saved selection. System locale and system layout switching do not change it.
See [language setup and switching](docs/languages.md).
For Japanese, pick **Romaji**, **Kana**, or **JIS** from the Language dropdown in Settings. Romaji and Kana compose text in the keyboard and use the system Anthy dictionary for kanji conversion. Space converts; Enter commits the composition. Another Enter sends a normal Enter key. JIS mode requires an existing system IME. See [Japanese input](docs/japanese.md).
Chinese and Korean compose locally and can be selected alongside English and integrated Japanese without a system layout change. [The language guide](docs/languages.md#chinese-and-korean-composition) explains candidates, Hangul, and input limitations.
Copy a bundled JSON profile from `~/.local/share/framekeyboard/current/share/framekeyboard/` into the matching folder below, edit it, then use the **Reload config** icon in Settings:
```text
~/.config/framekeyboard/layouts/
~/.config/framekeyboard/languages/
~/.config/framekeyboard/themes/
```
Use a new profile `id` to add a choice, or the same `id` to override a bundled one. [Configuration reference](docs/configuration.md) covers fields, favorites, validation, and saved placement.
## How it works
The keyboard is a native C++20 app. Cairo/Pango draw the keys, Vulkan/OpenVR display the panel, and libei sends native controls to the Frame's local Gamescope compositor. A feature-checked Gamescope text-input protocol sends characters resolved from the selected layout, including Japanese, Chinese and Korean composition. It does not run a browser or need an internet connection.
Copy and Paste send shortcuts; the app does not read clipboard contents. It does not log typed text or save composition history. Placement and profile preferences are stored locally.
The visible panel polls input and follows the dashboard at a target 60 Hz. While hidden, polling drops to 4 Hz. It redraws the texture only when its display changes or a key animation runs. With replacement enabled, closing the panel keeps the app resident for the next native request; Settings → Quit Full Keyboard exits it. With replacement off, closing the keyboard exits the process. Battery impact has not been benchmarked.
## Development and feedback
- [Build from source](docs/building.md)
- [Architecture and code map](docs/architecture.md)
- [Runtime and troubleshooting](docs/runtime.md)
- [Manual acceptance checks](docs/acceptance.md)
- [Roadmap](docs/plan.md) and [changelog](CHANGELOG.md)
- [Contributing and bug reports](CONTRIBUTING.md)
- [Release checklist](docs/releasing.md)
- [Third-party code and licenses](third_party/README.md)
Please report the app version, SteamOS version, affected app, whether it is a desktop or floating window, and steps to reproduce. Remove private text from screenshots and logs.
## License
Project code is available under the [MIT License](LICENSE). Vendored components keep their [upstream licenses](third_party/README.md).
## AI-generated project disclaimer
This project is fully AI-generated. The author is unfamiliar with C++, so please have patience and understanding. Bugs and rough edges are expected; careful reviews, clear bug reports, and contributions are welcome. Third-party components retain their original authorship and licenses.
+634
View File
@@ -0,0 +1,634 @@
#!/usr/bin/env python3
"""App-scoped CDP transport. Only localhost Steam is contacted; no listeners or files are created."""
import base64
from collections import deque
import hashlib
import ipaddress
import json
import math
import os
from pathlib import Path
import select
import signal
import socket
import struct
import subprocess
import sys
import time
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, ProxyHandler, build_opener
MAX_PACKET = 32768
GLOBAL = "__fullKeyboardNativeV1"
INSTALL_TIMEOUT = 2.0
ACTIVE_POLL = 0.035
IDLE_POLL = 0.075
def poll_interval(state):
# Missing/new/inconsistent metadata must retain the ordinary fast cadence.
busy = ('open', 'eligible', 'active', 'external', 'externalCandidate', 'externalRestore')
return IDLE_POLL if state.get('idle') is True and not any(state.get(k) for k in busy) else ACTIVE_POLL
def install_expression(source, token, external_supported, expires):
reference = f"globalThis.{GLOBAL}"
# Python and CEF run on the same device. A command queued beyond its local
# deadline must not install hooks later, even if CDP cleanup cannot get through.
return (f"(() => {{ if (Date.now() >= {expires}) return null; "
f"if ({reference}) {{ if (!{reference}.bridge.expired()) return false; "
f"{reference}.bridge.dispose(); delete {reference}; }} "
f"const bridge = ({source})(globalThis, undefined, {str(external_supported).lower()}); "
f"if (Date.now() >= {expires}) {{ bridge.dispose(); return null; }} "
f"{reference} = {{token: {json.dumps(token)}, bridge}}; return true; }})()")
def cleanup_expression(token, restore=True):
reference = f"globalThis.{GLOBAL}"
argument = "" if restore else "false"
return (f"(() => {{ const b = {reference}; if (b?.token === {json.dumps(token)}) {{"
f"b.bridge.dispose({argument}); delete {reference}; }} }})()")
class BridgeUnavailable(ValueError):
"""Only fixed, non-sensitive codes may cross the private status channel."""
def __init__(self, reason):
self.reason = reason if reason in {"debugger-blocked", "steam-incompatible",
"replacement-busy", "reply-timeout"} else "helper-unavailable"
super().__init__(self.reason)
def require_loopback_debugger():
"""Require the debugger endpoint we use to be bound to loopback."""
for table in ("tcp", "tcp6"):
try:
lines = Path("/proc/net", table).read_text().splitlines()[1:]
except FileNotFoundError:
if table == "tcp6":
continue # IPv6 can be disabled by the device.
raise BridgeUnavailable("debugger-blocked") from None
except OSError:
raise BridgeUnavailable("debugger-blocked") from None
for line in lines:
fields = line.split()
if fields[3] != "0A":
continue
host, port = fields[1].split(":")
if int(port, 16) != 8080:
continue
raw = bytes.fromhex(host)
if sys.byteorder == "little":
raw = b"".join(raw[i:i + 4][::-1] for i in range(0, len(raw), 4))
address = ipaddress.ip_address(raw)
mapped = getattr(address, "ipv4_mapped", None)
if not (mapped or address).is_loopback:
raise BridgeUnavailable("debugger-blocked")
class NoDebuggerRedirects(HTTPRedirectHandler):
def redirect_request(self, *_args, **_kwargs):
raise BridgeUnavailable("debugger-blocked")
class NativeGuard:
"""Separate native lease. EOF restores an armed exact-overlay request without CDP."""
def __init__(self):
self.socket = self.process = None
self.armed = False
executable = Path(__file__).resolve().parents[3] / "bin/framekeyboard-native-guard"
if not executable.is_file():
executable = Path(__file__).resolve().parent.parent / "framekeyboard-native-guard"
if not executable.is_file():
raise FileNotFoundError()
parent, child = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET)
self.socket = parent
parent.settimeout(.5)
try:
self.process = subprocess.Popen([str(executable), "--fd", str(child.fileno())],
pass_fds=(child.fileno(),), stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if json.loads(parent.recv(2048)).get("ok") is not True:
raise ValueError("native-guard-unavailable")
except BaseException:
self.close()
raise
finally:
child.close()
def command(self, op, **fields):
data = json.dumps(dict(op=op, **fields), separators=(",", ":")).encode()
if self.socket.send(data) != len(data):
raise ValueError("native-guard-backpressure")
return json.loads(self.socket.recv(2048)).get("ok") is True
def prepare(self, candidate, frame):
ok = self.command("prepare", key=candidate["key"], flags=candidate["flags"],
requireActive=candidate.get("requireActive") is True,
progress=frame["progress"], sent=frame["sent"], ready=frame["ready"])
if ok:
self.armed = True
return ok
def renew(self, frame):
self.command("renew", progress=frame["progress"], sent=frame["sent"], ready=frame["ready"])
def cancel(self):
if not self.command("cancel"):
raise ValueError("native-guard-cancel-failed")
self.armed = False
def restore(self):
if not self.command("restore"):
raise ValueError("native-guard-restore-failed")
self.armed = False
def close(self):
if self.socket:
self.socket.close()
self.socket = None
# An active native fallback belongs to this client. Leave the guard alive
# until Steam reports its closure, or VR shutdown would close it immediately.
if self.process:
try:
self.process.wait(timeout=.1)
except subprocess.TimeoutExpired:
pass
class CDP:
def __init__(self):
self.socket = None
self.pending = b""
self.sequence = 0
try:
require_loopback_debugger()
self.network_checked = time.monotonic()
opener = build_opener(ProxyHandler({}), NoDebuggerRedirects())
with opener.open("http://127.0.0.1:8080/json/list", timeout=0.4) as response:
data = response.read(1024 * 1024 + 1)
if len(data) > 1024 * 1024:
raise ValueError("target-list-too-large")
targets = json.loads(data)
target = next((t for t in targets if t.get("title") == "SharedJSContext"), None)
if target is None:
raise BridgeUnavailable("steam-incompatible")
url = urlsplit(target["webSocketDebuggerUrl"])
if url.scheme != "ws" or url.hostname not in ("localhost", "127.0.0.1") or url.port != 8080:
raise BridgeUnavailable("debugger-blocked")
self.socket = socket.create_connection(("127.0.0.1", 8080), timeout=0.3)
key = base64.b64encode(os.urandom(16)).decode()
path = url.path + ("?" + url.query if url.query else "")
self.socket.sendall((f"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:8080\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
while b"\r\n\r\n" not in self.pending:
chunk = self.socket.recv(4096)
if not chunk:
raise EOFError()
self.pending += chunk
if len(self.pending) > 16384:
raise ValueError("invalid-upgrade")
header, self.pending = self.pending.split(b"\r\n\r\n", 1)
accept = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest())
if not header.startswith(b"HTTP/1.1 101 ") or accept.lower() not in header.lower():
raise ValueError("upgrade-refused")
except BaseException:
self.close()
raise
def close(self):
if self.socket:
self.socket.close()
self.socket = None
def peek(self, count, deadline):
while len(self.pending) < count:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError()
self.socket.settimeout(remaining)
data = self.socket.recv(min(65536, count - len(self.pending)))
if not data:
raise EOFError()
self.pending += data
return self.pending[:count]
def receive_frame(self, deadline):
# Do not consume a partial frame on timeout. The next call must be able
# to drain a late reply, including one interrupted inside its payload.
first, second = self.peek(2, deadline)
length, header = second & 127, 2
if length == 126:
length, header = struct.unpack("!H", self.peek(4, deadline)[2:4])[0], 4
elif length == 127:
length, header = struct.unpack("!Q", self.peek(10, deadline)[2:10])[0], 10
if length > 1024 * 1024 or second & 128 or not first & 0x80 or first & 0x70:
raise ValueError("invalid-debug-frame")
data = self.peek(header + length, deadline)[header:]
self.pending = self.pending[header + length:]
return first & 15, data
def frame(self, opcode, data, deadline):
size = len(data)
header = bytes([0x80 | opcode])
if size < 126:
header += bytes([0x80 | size])
elif size < 65536:
header += bytes([0xfe]) + struct.pack("!H", size)
else:
header += bytes([0xff]) + struct.pack("!Q", size)
mask = os.urandom(4)
self.socket.settimeout(max(0.001, deadline - time.monotonic()))
try:
self.socket.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
except OSError as error:
# A partial write cannot safely be retried on the same stream.
self.close()
raise ConnectionError("debugger-write-failed") from error
def evaluate(self, expression, timeout=0.4):
if time.monotonic() - self.network_checked >= 1:
require_loopback_debugger()
self.network_checked = time.monotonic()
self.sequence += 1
command = {"id": self.sequence, "method": "Runtime.evaluate", "params": {
"expression": expression, "returnByValue": True, "silent": True}}
deadline = time.monotonic() + timeout
self.frame(1, json.dumps(command, ensure_ascii=True).encode(), deadline)
while time.monotonic() < deadline:
opcode, payload = self.receive_frame(deadline)
if opcode == 9:
self.frame(10, payload, deadline)
continue
if opcode == 8:
raise EOFError()
if opcode != 1:
continue
message = json.loads(payload)
if message.get("id") != self.sequence:
continue
if "exceptionDetails" in message.get("result", {}) or "error" in message:
raise BridgeUnavailable("steam-incompatible")
return message.get("result", {}).get("result", {}).get("value")
raise TimeoutError()
def send(parent, value):
data = json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode()
if len(data) > MAX_PACKET or parent.send(data) != len(data):
raise ValueError("parent-backpressure")
def report_unavailable(parent, reason):
# Closing a seqpacket peer with unread frames discards its final reply with
# ECONNRESET. Refuse new writes and drain the bounded queue before reporting.
parent.shutdown(socket.SHUT_RD)
for _ in range(1024):
if not parent.recv(MAX_PACKET + 1, socket.MSG_DONTWAIT):
break
send(parent, {"op": "unavailable", "reason": reason})
class WatchdogProgress:
"""Private pipe from the overlay watchdog, independent of the VR/main loop."""
def __init__(self, fd):
self.fd = fd
self.pending = b""
self.sent = None
self.slow_until = 0
self.runtime_until = 0
def poll(self):
if self.fd is None:
return
while True:
try:
data = os.read(self.fd, 4096)
except BlockingIOError:
break
if not data:
break
self.pending += data
while len(self.pending) >= 24:
sent, slow_until, runtime_until = struct.unpack("=ddd", self.pending[:24])
self.pending = self.pending[24:]
if math.isfinite(sent) and 0 < sent <= time.monotonic() and \
(self.sent is None or sent > self.sent):
self.sent = sent
self.slow_until = slow_until if math.isfinite(slow_until) and \
0 < slow_until <= sent + 30 else 0
self.runtime_until = runtime_until if math.isfinite(runtime_until) and \
0 < runtime_until <= sent + 30 else 0
def close(self):
if self.fd is not None:
os.close(self.fd)
self.fd = None
class ParentRecovery:
def __init__(self):
self.terminated_at = None
self.boot_gap = None
self.resume_until = 0
self.resume_at = 0
self.resume_frame = None
@staticmethod
def parent_stopped(owner):
try:
# comm can contain spaces and parentheses. The state follows its
# final closing parenthesis; only a signal stop uses uppercase T.
fields = Path(f"/proc/{owner}/stat").read_text().rsplit(") ", 1)[1].split()
return fields[0] == "T"
except (OSError, IndexError):
return False # An unreadable state leaves the normal deadlines in place.
def observe_clock(self, now, boot_now, frame_sent):
# BOOTTIME includes system suspend; MONOTONIC and the frame clock do not.
gap = boot_now - now
if self.boot_gap is not None and gap - self.boot_gap > .5 and self.resume_frame != frame_sent:
self.resume_at, self.resume_until, self.resume_frame = now, now + 30, frame_sent
self.boot_gap = gap
if frame_sent > self.resume_at:
self.resume_until = 0
def check(self, owner, watch, frame_sent, watchdog_sent, slow_until=0, runtime_until=0):
now = time.monotonic()
if not owner or not watch or os.getppid() != owner:
self.terminated_at = None
return False
stopped = self.parent_stopped(owner)
if os.getppid() != owner:
self.terminated_at = None
return False
if stopped:
# Neither the main loop nor its watchdog can run after SIGSTOP, and
# SIGTERM would remain pending. Remove the frozen panel's owner now.
os.kill(owner, signal.SIGKILL)
return True
if now - frame_sent < 3:
self.terminated_at = None
return False
if self.terminated_at is None and (now < self.resume_until or
now < min(runtime_until, frame_sent + 30)):
return False
# A live overlay watchdog does not prove main-loop progress. Only an
# explicit slow operation may delay recovery, never beyond 30 seconds
# from the last completed frame. It cannot cancel a termination begun.
if self.terminated_at is None and watchdog_sent is not None and \
0 <= now - watchdog_sent < 3 and now < min(slow_until, frame_sent + 30):
return False
if self.terminated_at is None:
os.kill(owner, signal.SIGTERM)
self.terminated_at = now
elif now - self.terminated_at >= 1:
os.kill(owner, signal.SIGKILL)
return True
return False
def run(parent, client_factory=CDP, parent_pid=None, guard_factory=NativeGuard, health_fd=None):
client = None
guard = None
install_attempted = False
watchdog = WatchdogProgress(health_fd)
recovery_watchdog = ParentRecovery()
token = os.urandom(16).hex()
reference = f"globalThis.{GLOBAL}"
failure_reason = "debugger-unavailable"
restore_native = True
try:
client = client_factory()
try:
guard = guard_factory()
except (OSError, ValueError, EOFError):
pass # Steam targets remain supported when the native adapter is unavailable.
failure_reason = "helper-unavailable"
source = Path(__file__).with_name("steam_bridge.js").read_text()
failure_reason = "steam-incompatible"
# Never displace another live instance. A dead owner's lease must expire first.
install_attempted = True
result = client.evaluate(install_expression(source, token, guard is not None,
int((time.time() + INSTALL_TIMEOUT) * 1000)),
timeout=INSTALL_TIMEOUT)
if result is None:
raise TimeoutError() # The expression itself rejected a late installation.
if result is not True:
raise BridgeUnavailable("replacement-busy")
failure_reason = "connection-lost"
frame = {"progress": 0, "ready": False, "ack": 0, "fallback": False,
"sent": time.monotonic()}
frame_sent = time.monotonic()
last_parent = time.monotonic()
watch_main_loop = False
fallback_request = None
external_ack = 0
next_poll = 0.0
pending = deque()
held_keys = {}
pending_repeats = set()
stopping = False
consecutive_timeouts = 0
def evaluate(expression):
nonlocal consecutive_timeouts
try:
result = client.evaluate(expression)
except TimeoutError:
consecutive_timeouts += 1
if consecutive_timeouts >= 3:
raise BridgeUnavailable("reply-timeout") from None
raise
consecutive_timeouts = 0
return result
while True:
if not Path(__file__).is_file() or not Path(__file__).with_name("steam_bridge.js").is_file():
break # Removing an installed app also withdraws a still-running bridge.
readable, _, _ = select.select([parent], [], [],
0 if pending else max(0, next_poll - time.monotonic()))
# Drain releases before dispatching more work. CDP can be slower than
# repeat generation; initial clicks stay ordered, repeats do not pile up.
received = 0
while readable:
received += 1
if received > 256:
raise ValueError("parent-backlog")
packet = parent.recv(MAX_PACKET + 1)
if not packet:
stopping = True
break
if len(packet) > MAX_PACKET:
raise ValueError("oversize-packet")
message = json.loads(packet)
operation = message.get("op")
if operation == "stop":
# Only an explicit prepared-update shutdown may suppress
# recovery. EOF, signals and malformed packets still restore.
restore_native = message.get("restore") is not False
stopping = True
break
if operation == "frame":
if type(message.get("progress")) is not int or type(message.get("ready")) is not bool:
raise ValueError("invalid-frame")
frame = {k: message[k] for k in ("progress", "ready", "ack", "sent")}
# Restoration acknowledgements must survive subsequent frame packets.
frame["externalRestored"] = external_ack
if guard:
guard.renew(frame)
if message.get("fallback") is True:
fallback_request = message.get("fallbackRequest", 0)
next_poll = 0
last_parent = time.monotonic()
frame_sent = float(message["sent"])
watch_main_loop = message.get("watch") is True
elif operation == "key-up":
if type(message.get("id")) is not int or type(message.get("request")) is not int:
raise ValueError("invalid-release")
key_id = message["id"]
held = held_keys.get(key_id)
if held and held[0] == message["request"]:
del held_keys[key_id]
cancelled_repeat = key_id in pending_repeats
pending_repeats.discard(key_id)
pending = deque(m for m in pending if not (
m["op"] == "key" and m["value"] == 2 and m["id"] == key_id))
if cancelled_repeat:
# No deletion after release, but the parent still
# needs completion for the repeat it submitted.
send(parent, {"op": "key-result", "id": key_id, "ok": True})
elif operation == "dismiss":
if type(message.get("request")) is not int:
raise ValueError("invalid-dismissal")
pending.append(message)
elif operation in ("text", "key"):
if type(message.get("request")) is not int or not isinstance(message.get("text"), str):
raise ValueError("invalid-input")
if len(message["text"].encode()) > 8192:
raise ValueError("oversize-input")
if operation == "key":
if type(message.get("id")) is not int or message["id"] <= 0 or \
type(message.get("value")) is not int or message["value"] not in (1, 2):
raise ValueError("invalid-key")
key_id = message["id"]
identity = (message["request"], message["text"])
if message["value"] == 1:
if key_id in held_keys or len(held_keys) >= 128:
raise ValueError("invalid-key-hold")
held_keys[key_id] = identity
pending.append(message)
elif held_keys.get(key_id) == identity and key_id not in pending_repeats:
pending_repeats.add(key_id)
pending.append(message)
else:
pending.append(message)
else:
raise ValueError("unknown-operation")
if len(pending) > 128:
raise ValueError("input-backlog")
readable, _, _ = select.select([parent], [], [], 0)
if stopping:
break
if pending:
message = pending.popleft()
operation = message["op"]
try:
if operation == "dismiss":
ok = evaluate(f"{reference}?.bridge.dismiss({message['request']}) === true")
next_poll = 0
else:
if operation == "key" and message["value"] == 2:
pending_repeats.discard(message["id"])
# The JS side checks the target and lease again at delivery.
ok = evaluate(f"{reference}?.bridge.send({message['request']},"
f"{json.dumps(message['text'], ensure_ascii=True)},"
f"{str(operation == 'key').lower()}) === true")
except TimeoutError:
ok = False # Unknown delivery fails this request closed.
if operation != "dismiss":
send(parent, {"op": f"{operation}-result", "id": message["id"], "ok": ok is True})
if ok is not True:
fallback_request = message["request"]
watchdog.poll()
recovery_watchdog.observe_clock(time.monotonic(), time.clock_gettime(time.CLOCK_BOOTTIME), frame_sent)
if not select.select([parent], [], [], 0)[0] and recovery_watchdog.check(
parent_pid, watch_main_loop, frame_sent, watchdog.sent, watchdog.slow_until,
watchdog.runtime_until):
break
if time.monotonic() - last_parent > 0.7 or not 0 <= time.monotonic() - frame_sent < 0.7:
frame["ready"] = False
if time.monotonic() >= next_poll:
poll_frame = {**frame, "fallback": fallback_request is not None,
"fallbackRequest": fallback_request}
try:
result = evaluate(f"{reference}?.bridge.step({json.dumps(poll_frame)})")
if not isinstance(result, dict) or result.get("version") != 1 or result.get("unavailable"):
raise BridgeUnavailable("steam-incompatible")
if guard:
recovery = result.get("externalRestore")
if recovery and recovery["request"] != external_ack:
guard.restore()
external_ack = frame["externalRestored"] = recovery["request"]
candidate = result.get("externalCandidate")
if candidate:
# Dismissal and resummon can both occur between polls. A
# candidate has no logical owner, so any old lease must
# be cancelled before the next exact target is armed.
if guard.armed:
guard.cancel()
if guard.prepare(candidate, frame):
result = evaluate(f"{reference}?.bridge.prepareExternal("
f"{candidate['request']},{json.dumps(candidate['key'])})")
if not isinstance(result, dict):
raise BridgeUnavailable("steam-incompatible")
recovery = result.get("externalRestore")
if recovery and recovery["request"] != external_ack:
guard.restore()
external_ack = frame["externalRestored"] = recovery["request"]
elif not result.get("external") and guard.armed:
guard.cancel()
except TimeoutError:
# step is idempotent. Keep the last state/fallback request;
# the JS lease and C++ freshness timeout remain independent.
next_poll = time.monotonic() + ACTIVE_POLL
continue
fallback_request = None
send(parent, {"op": "state", **result})
next_poll = time.monotonic() + poll_interval(result)
except (Exception, SystemExit) as error:
# Errors are fixed status codes. Never echo CDP expressions, input packets or field values.
try:
reason = error.reason if isinstance(error, BridgeUnavailable) else failure_reason
if isinstance(error, TimeoutError) and failure_reason == "steam-incompatible":
reason = "install-timeout"
report_unavailable(parent, reason)
except Exception:
pass
finally:
if client:
if install_attempted:
try:
# A lost reply does not prove the install failed. The token
# prevents this attempt's cleanup from removing a successor.
client.evaluate(cleanup_expression(token, restore_native))
if guard and guard.armed:
if restore_native:
guard.restore()
else:
guard.cancel()
except Exception:
pass # Steam-side watchdog restores presentation even if CDP disappeared.
client.close()
if guard:
guard.close()
watchdog.close()
parent.close()
if __name__ == "__main__":
# SIGTERM follows the same cleanup path as EOF from the owning application.
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
if len(sys.argv) != 5 or sys.argv[1] != "--fd" or sys.argv[3] != "--health-fd":
sys.exit(2)
run(socket.socket(fileno=int(sys.argv[2])), parent_pid=os.getppid(), health_fd=int(sys.argv[4]))
+672
View File
@@ -0,0 +1,672 @@
// Evaluated only in Steam's SharedJSContext. Never read field or clipboard text.
// Native presentation is closed; input stays bound to the owning Steam window.
(function installNativeBridge(root, testStore, externalSupported) {
"use strict";
const VERSION = 1, LEASE_MS = 900, REQUEST_MS = 400, IDLE_INSPECT_MS = 100;
const now = () => root.performance.now();
let store = testStore;
if (!store) {
let require;
const chunks = root.webpackChunksteamui;
if (!chunks || typeof chunks.push !== "function") throw Error("steam-ui-unavailable");
const id = Symbol("framekeyboard");
chunks.push([[id], {}, r => { require = r; }]);
const index = chunks.findIndex(c => c[0]?.[0] === id);
if (index >= 0) chunks.splice(index, 1);
for (const key of Object.keys(require?.m || {})) {
if (!String(require.m[key]).includes("get VRKeyboardStatus()")) continue;
for (const value of Object.values(require(key))) {
if (value && typeof value === "object" && "VRKeyboardStatus" in value &&
value.m_VRKeyboardState) { store = value; break; }
}
if (store) break;
}
}
const popup = root.g_PopupManager?.GetPopupForVROverlayKey?.("valve.steam.gamepadui.keyboard");
const win = popup?.m_popup || popup?.window;
const windowApi = win?.SteamClient?.Window;
const windows = root.SteamUIStore?.WindowStore?.SteamUIWindows;
const managers = Array.isArray(windows) ? windows.map(w => w.VirtualKeyboardManager).filter(Boolean) : [];
if (!store?.m_VRKeyboardState || !win?.document || !windowApi || !managers.length ||
typeof windowApi.ShowWindow !== "function" || typeof windowApi.BringToFront !== "function")
throw Error("steam-hooks-unavailable");
function nativeStatus() {
// VRKeyboardStatus also contains initial text. Read only these raw metadata fields.
if (testStore) {
const s = store.VRKeyboardStatus;
return {open: s.bIsOpen, key: s.sOverlayKey, dispatch: s.bDispatchEventsToSteamVR,
flags: s.bMinimal ? 13 : 0};
}
const s = store.m_VRKeyboardState;
if (typeof s?.m_bIsOpen !== "boolean") throw Error("steam-status-changed");
return {open: s.m_bIsOpen, key: s.m_sOpenForOverlayKey,
dispatch: s.m_bDispatchEventsToSteamVR, flags: s.m_eVRKeyboardFlags};
}
const hooks = [], owners = [];
const keyboardApi = root.SteamClient?.OpenVR?.Keyboard;
const externalHide = keyboardApi?.Hide;
const localTarget = s => externalSupported && s.dispatch === false && s.flags === 13 &&
/^gamescope\.gamescope-[0-9]+(?:\.window\.[0-9]+)?$/.test(s.key) && typeof externalHide === "function";
let preparedExternal = null, externalRestore = null, handoff = null;
const localFrames = new Map();
function selectedFrame() {
const f = root.vrFrameStore?.activeFrame;
if (!f || !Number.isSafeInteger(f.frameID)) return null;
return {id: f.frameID, visible: f.isVisible === true,
keys: Array.from(f.steamUIVROverlayKeysInPages || []).filter(key => ownSteam(key))};
}
function hook(object, key, wrap) {
const descriptor = Object.getOwnPropertyDescriptor(object, key), original = object[key];
if (typeof original !== "function" || descriptor && !descriptor.configurable && !descriptor.writable)
throw Error("steam-hook-readonly");
const replacement = wrap(original);
Object.defineProperty(object, key, {configurable: true, writable: true, value: replacement});
hooks.push(() => {
if (object[key] !== replacement) return;
if (descriptor) Object.defineProperty(object, key, descriptor);
else delete object[key];
});
return original;
}
const ownSteam = key => typeof key === "string" && (key === "valve.steam.gamepadui.main" ||
key === "valve.steam.gamepadui.barpopup" || key.startsWith("valve.steam.gamepadui.appoverlay."));
let native = nativeStatus(), previousOpen = native.open, previousKey = native.key;
let ignoreOpen = native.open, rawDeadline = 0, restoringUntil = 0;
let restoringOwner = null, restoreTimer;
let logical = null, closingNative = false, deadline = 0, request = 1, acknowledged = 0;
let expectedClose = null;
let eventSequence = 0;
const events = [];
function record(code) {
events.push({id: ++eventSequence, request, code});
if (events.length > 32) events.shift();
}
let lease = 0, progress = -1, disposed = false, inspecting = false, interceptedShow = false;
let lastClock = now(), lastStep = now(), lastInspection = now(), timer, subscription;
const oldWindowShow = windowApi.ShowWindow;
const style = win.document.createElement("style");
style.setAttribute("data-framekeyboard-native", "1");
style.textContent = ":root, body { opacity: 0 !important; pointer-events: none !important; }";
let styled = false;
function setHidden(hidden) {
if (hidden && !styled) { win.document.documentElement.appendChild(style); styled = true; }
if (!hidden && styled) { style.remove(); styled = false; }
}
const fresh = () => !disposed && now() < lease;
function editingDocument(manager) {
const d = manager.m_ownerWindow?.document;
return d && typeof d.execCommand === "function" &&
typeof d.getSelection?.()?.modify === "function" &&
typeof d.defaultView?.KeyboardEvent === "function" &&
d.queryCommandSupported?.("insertText") && d.queryCommandSupported?.("delete") ? d : null;
}
const editableIn = (e, d) => e?.isConnected && e.ownerDocument === d && !e.disabled && !e.readOnly &&
(e.tagName === "TEXTAREA" || e.tagName === "INPUT" &&
["text", "search", "password", "tel", "url"].includes(e.type));
function routeFor(owner) {
const m = owner.manager;
if (owner.document?.visibilityState === "hidden") return null;
if (typeof m.m_ActiveElementProps?.onTextEntered === "function") return {kind: "callback"};
const d = owner.document;
if (!d || m.m_ownerWindow?.document !== d) return null;
const e = d.activeElement;
const ref = m.m_currentVirtualKeyboardRef || m.m_lastActiveVirtualKeyboardRef;
if (editableIn(e, d) && typeof ref?.BIsElementValidForInput === "function" &&
(owner.focusRefs.get(ref) === e || ref.BIsElementValidForInput() === true) && (m.m_currentVirtualKeyboardRef ||
typeof ref.SetAsCurrentVirtualKeyboardTarget === "function"))
return {kind: "dom", element: e, ref};
// A button can summon the panel without a field. Such a session can
// navigate this window but never sends text to global compositor focus.
return !m.m_currentVirtualKeyboardRef && !m.m_ActiveElementProps ? {kind: "dashboard"} : null;
}
const routeReady = owner => !!routeFor(owner);
function sendDom(session, text, key) {
const d = session.owner.document, element = session.route.element;
if (!key) {
// With no field, ordinary typing has no recipient. Never queue it
// for a later field or forward it to a background application.
return !element || d.execCommand("insertText", false, text) === true;
}
if (text === "VKClose") { releaseLogical(false); return true; }
const codes = {Backspace: 8, Enter: 13, Tab: 9, ArrowLeft: 37, ArrowUp: 38,
ArrowRight: 39, ArrowDown: 40};
if (!(text in codes)) return false;
if (element && text === "Enter" && session.props?.onEnterKeyPress != null) {
const enter = session.props.onEnterKeyPress;
const action = typeof enter === "function" ? enter.call(session.props) : enter;
if (!action) return true;
if (action === "VKClose") { releaseLogical(false); return true; }
if (action !== "Enter") return false;
}
const target = element || d.activeElement || d.body;
const event = type => new d.defaultView.KeyboardEvent(type,
{key: text, code: text, keyCode: codes[text], which: codes[text],
bubbles: true, cancelable: true});
const accepted = target.dispatchEvent(event("keydown"));
try {
if (!accepted || element && d.activeElement !== element) return true;
if (text === "Tab") {
const items = [...d.querySelectorAll("input,textarea,button,a[href],[tabindex]")]
.filter(e => !e.disabled && e.tabIndex >= 0 && e.getClientRects().length)
.sort((a, b) => (a.tabIndex || Infinity) - (b.tabIndex || Infinity));
const next = items[(items.indexOf(d.activeElement) + 1) % items.length];
next?.focus();
} else if (element) {
if (text === "Backspace") d.execCommand("delete", false, null);
else if (text.startsWith("Arrow")) {
const backward = text === "ArrowLeft" || text === "ArrowUp";
d.getSelection().modify("move", backward ? "backward" : "forward",
text === "ArrowLeft" || text === "ArrowRight" ? "character" : "line");
} else if (text === "Enter") {
if (element.tagName === "TEXTAREA") d.execCommand("insertText", false, "\n");
else element.form?.requestSubmit?.();
}
}
return true;
} finally { target.dispatchEvent(event("keyup")); }
}
function potential(s) {
return localTarget(s) || ownSteam(s.key) && (s.dispatch === false || (s.flags & 1) !== 0) &&
owners.some(o => o.key === s.key && routeReady(o));
}
function candidate(s) {
if (localTarget(s)) return preparedExternal?.key === s.key ? preparedExternal : null;
const choices = owners.filter(o => {
const route = routeFor(o);
return o.key === s.key && route && (route.kind !== "callback" ||
typeof o.manager.m_fnVROnTextEnteredOverride === "function");
});
return potential(s) && choices.length === 1 ? choices[0] : null;
}
const targetOf = manager => manager.m_currentVirtualKeyboardRef || manager.m_ActiveElementProps || null;
function fieldRequest(s) {
const owner = candidate(s), intent = owner?.fieldIntent;
return owner && !owner.external && intent && now() < intent.until &&
intent.target === targetOf(owner.manager) ? owner : null;
}
function buttonRequest(s) {
if (localTarget(s)) return true;
const owner = candidate(s);
// Dashboard buttons bypass the field manager. A retained field reference
// can be stale after returning from an app, so it is not proof of focus.
return owner?.fieldHook && !fieldRequest(s);
}
function blocking() {
return fresh() && !ignoreOpen && !restoringUntil &&
(logical || !native.open || potential(native) && now() < rawDeadline);
}
function restoreWindow() {
setHidden(false);
if (interceptedShow && native.open) oldWindowShow.call(windowApi);
interceptedShow = false;
}
function unsubscribe() {
if (typeof subscription === "function") subscription();
else subscription?.unregister?.();
subscription = null;
}
function finishRestore(success) {
const owner = restoringOwner;
restoringOwner = null;
restoringUntil = 0;
root.clearTimeout(restoreTimer);
if (disposed) unsubscribe();
if (owner) record(success ? "restore-complete" : "restore-failed");
if (owner && !success) owner.setOpen.call(owner.manager, false);
}
function checkRestore(final = false) {
if (!restoringOwner) return;
const status = nativeStatus();
if (status.open && status.key === restoringOwner.key) finishRestore(true);
else if (status.open || final || now() >= restoringUntil) finishRestore(false);
}
function releaseLogical(restore, closeNative = true,
reason = restore ? "release-fallback" : "release-dismissed") {
const session = logical;
// Expiry runs before ordinary competing-owner inspection, and disposal
// may precede a status callback. Always check the current provider here.
try { native = nativeStatus(); } catch (_) {
// Unknown presentation cannot authorize Show/Hide. Still release
// our retained manager even if Steam's status interface changed.
native = {open: false};
restore = closeNative = false;
}
if (restore && session && !session.owner.external &&
session.owner.document?.visibilityState === "hidden") {
restore = closeNative = false;
reason = "owner-hidden";
} else if (restore && session?.route?.kind === "dom") {
const current = routeFor(session.owner);
if (current?.kind !== "dom" || current.element !== session.route.element ||
targetOf(session.owner.manager) !== session.target) {
// Blur can precede Steam clearing its ref, even on expiry or
// helper shutdown. Do not revive that field or hide a newer one.
restore = closeNative = false;
reason = "target-cleared";
}
}
logical = null;
handoff = null;
expectedClose = null;
closingNative = false;
acknowledged = 0;
++request;
ignoreOpen = true;
if (!restore) interceptedShow = false;
restoreWindow();
if (!session) return;
if (restore && native.open && native.key !== session.owner.key) {
record("competing-owner");
if (!session.owner.external) session.owner.setOpen.call(session.owner.manager, false);
preparedExternal = null;
return;
}
record(reason);
if (session.owner.external) {
if (restore) {
externalRestore = {key: session.owner.key, request};
restoringUntil = now() + REQUEST_MS;
} else if (closeNative) session.owner.hide.call(keyboardApi);
preparedExternal = null;
} else if (restore &&
(!session.target || targetOf(session.owner.manager))) {
// Preserve the field while the exact owning window's original provider opens again.
restoringUntil = now() + REQUEST_MS;
restoringOwner = session.owner;
// Disposal removes the hooks and regular timer. This bounded check
// still clears a failed restore without touching a newer provider.
restoreTimer = root.setTimeout(() => {
try { checkRestore(true); } catch (_) { finishRestore(false); }
}, REQUEST_MS);
session.owner.show.call(session.owner.api);
oldWindowShow.call(windowApi);
} else {
// A missing field cannot justify closing another provider's newer
// native request while fallback withdraws the old session.
if (closeNative && (!restore || !native.open || native.key === session.owner.key))
session.owner.hide.call(session.owner.api);
// Hide can fail or complete asynchronously. An already-open native
// keyboard for this owner must retain its ordinary Steam state.
let status;
try { status = nativeStatus(); } catch (_) { /* Ownership must still be released. */ }
if (!status?.open || status.key !== session.owner.key)
session.owner.setOpen.call(session.owner.manager, false);
}
}
function begin(owner) {
const route = owner.external ? null : routeFor(owner);
// Steam clears its current ref on X. Restore only its own still-valid
// last field, in response to this new explicit native summon.
if (route?.kind === "dom" && !owner.manager.m_currentVirtualKeyboardRef)
route.ref.SetAsCurrentVirtualKeyboardTarget();
const frame = selectedFrame();
logical = {owner, target: owner.external ? owner.key : targetOf(owner.manager),
props: owner.manager?.m_ActiveElementProps, route, frame: frame?.id};
handoff = null;
if (owner.external && frame?.visible && frame.keys.length === 0) {
localFrames.delete(frame.id);
localFrames.set(frame.id, owner.key);
if (localFrames.size > 16) localFrames.delete(localFrames.keys().next().value);
}
++request;
record("begin");
acknowledged = 0;
deadline = now() + REQUEST_MS;
closingNative = !!native.open;
expectedClose = !native.open || owner.external ? null : {owner, request};
owner.fieldIntent = null;
if (!owner.external) owner.setOpen.call(owner.manager, true);
// Window.HideWindow leaves dashboard scene/hit geometry alive and can
// trigger focus-driven reopening. Close presentation through its provider.
if (native.open) owner.hide.call(owner.external ? keyboardApi : owner.api);
}
function inspect() {
if (inspecting || disposed) return;
inspecting = true;
try {
const time = now();
lastInspection = time;
if (time - lastClock > LEASE_MS || time < lastClock) lease = 0;
lastClock = time;
if (win.closed || root.g_PopupManager.GetPopupForVROverlayKey("valve.steam.gamepadui.keyboard") !== popup)
throw Error("steam-popup-changed");
native = nativeStatus();
checkRestore();
if (!restoringOwner && restoringUntil && (native.open || time >= restoringUntil)) restoringUntil = 0;
if (!native.open && !restoringUntil) ignoreOpen = false;
const newOpen = native.open && (!previousOpen || native.key !== previousKey);
if (newOpen) rawDeadline = time + REQUEST_MS;
previousOpen = native.open;
previousKey = native.key;
const frame = selectedFrame();
if (logical && frame && logical.frame != null && logical.frame !== frame.id && !native.open) {
// Frame selection can leave a hidden Steam input active in its
// document. It is not the typing owner after an app switch.
const key = frame.keys.length === 1 ? frame.keys[0] : localFrames.get(frame.id);
releaseLogical(false, false, "owner-changed");
if (fresh() && frame.visible && key) {
handoff = {key, frame: frame.id, until: time + REQUEST_MS};
ignoreOpen = false;
}
}
if (handoff) {
if (!fresh() || time >= handoff.until || native.open)
handoff = null;
else if (handoff.previous != null) {
// Visibility and selected-tab metadata arrive separately.
// Stop old input immediately, then wait within the same
// bounded handoff for the new visible selected frame.
if (frame?.visible && frame.id !== handoff.previous) {
const key = frame.keys.length === 1 ? frame.keys[0] : localFrames.get(frame.id);
handoff = key ? {...handoff, key, frame: frame.id, previous: null} : null;
}
} else if (!frame?.visible || frame.id !== handoff.frame) handoff = null;
}
if (handoff && ownSteam(handoff.key)) {
const choices = owners.filter(o => o.key === handoff.key && routeReady(o));
if (choices.length === 1) begin(choices[0]);
}
if (logical && !logical.owner.external && logical.owner.document?.visibilityState === "hidden") {
const previous = logical.frame;
releaseLogical(false, false, "owner-hidden");
if (fresh() && previous != null)
handoff = {previous, until: time + REQUEST_MS};
}
if (logical) {
if (!fresh() || time >= deadline && acknowledged !== request) {
releaseLogical(true, true, fresh() ? "request-timeout" : "lease-expired");
} else if (newOpen && !closingNative && acknowledged === request && buttonRequest(native)) {
// The resident panel is already open, even if its previous app
// was closed. Consume this button toggle across supported owners.
releaseLogical(false);
} else if (newOpen && !closingNative && fieldRequest(native)) {
const owner = fieldRequest(native), previous = logical.owner;
logical = null;
expectedClose = null;
preparedExternal = null;
if (!previous.external && previous !== owner)
previous.setOpen.call(previous.manager, false);
begin(owner);
record("target-changed");
} else if (native.open && (native.key !== logical.owner.key || !potential(native))) {
// A different provider can open before our close acknowledgement.
// Its native session must survive; never reopen the old owner over it.
const previous = logical.owner;
logical = null;
expectedClose = null;
if (!previous.external && native.key !== previous.key)
previous.setOpen.call(previous.manager, false);
closingNative = false;
acknowledged = 0;
++request;
record("competing-owner");
ignoreOpen = true;
preparedExternal = null;
} else if (closingNative) {
if (!native.open && !expectedClose) closingNative = false;
} else if (newOpen) {
const owner = candidate(native);
const changedTarget = owner !== logical.owner ||
owner && !owner.external && targetOf(owner.manager) !== logical.target;
if (changedTarget) {
const previous = logical.owner;
logical = null;
expectedClose = null;
if (!previous.external && previous !== owner)
previous.setOpen.call(previous.manager, false);
if (owner) begin(owner);
else { acknowledged = 0; ++request; ignoreOpen = true; }
} else {
// Native presentation is closed, so the next same-target
// native button Show toggles our resident panel off.
releaseLogical(false);
}
} else if (logical.owner.external) {
// Gamescope input stays on the existing local libei/Unicode backend.
} else {
const m = logical.owner.manager;
// React can replace props after every character while the field
// reference stays stable. Refresh callbacks without retargeting.
logical.props = m.m_ActiveElementProps;
const target = targetOf(m), route = routeFor(logical.owner);
if (!target && target !== logical.target || logical.route?.kind === "dom" &&
(!route || route.kind === "dashboard")) {
// Field loss proves only that Steam no longer owns
// this target. It cannot authorize libei input into
// whichever local app currently has compositor focus.
releaseLogical(false, true, "target-cleared");
} else if (!route || route.kind === "callback" &&
typeof m.m_fnVROnTextEnteredOverride !== "function") {
releaseLogical(true);
} else if (target !== logical.target || route.kind !== logical.route?.kind ||
route.element !== logical.route?.element) {
logical.target = target;
logical.route = route;
++request;
acknowledged = 0;
deadline = time + REQUEST_MS;
record("target-changed");
}
}
} else if (native.open && !ignoreOpen && !restoringUntil) {
const owner = candidate(native);
if (fresh() && owner && time < rawDeadline) begin(owner);
else if (!fresh() || !potential(native) || time >= rawDeadline) ignoreOpen = true;
}
// A show can arrive before its native status. Replay it when that
// request falls back, even if we never acquired a logical session.
if (blocking()) setHidden(true);
else restoreWindow();
} finally { inspecting = false; }
}
function idle() {
return !logical && !native.open && !handoff && !preparedExternal && !externalRestore &&
!restoringOwner && !restoringUntil && !expectedClose && !closingNative && !interceptedShow;
}
function snapshot() {
const eligible = !!logical && !closingNative && !ignoreOpen;
return {version: VERSION, request, open: !!logical || !!native.open || !!handoff, eligible, idle: idle(),
active: eligible && acknowledged === request && fresh(),
local: !!logical?.owner.external,
external: logical?.owner.external ? {key: logical.owner.key, request} : null,
externalCandidate: handoff && !native.open && externalSupported &&
/^gamescope\.gamescope-[0-9]+(?:\.window\.[0-9]+)?$/.test(handoff.key) ?
{key: handoff.key, flags: 13, request, requireActive: true} :
!logical && !ignoreOpen && native.open && localTarget(native) &&
fresh() && now() < rawDeadline ? {key: native.key, flags: native.flags, request} : null,
externalRestore, events: events.slice()};
}
function dispose(restore = true) {
if (disposed) return;
lease = 0;
try {
let closeNative = true;
if (restore === false) {
// Update cleanup can race a new owner's native request. Release
// our logical flag without hiding that unrelated presentation.
try {
const current = nativeStatus();
closeNative = !current.open || current.key === logical?.owner.key;
} catch (_) { closeNative = false; }
}
releaseLogical(restore !== false, closeNative);
restoreWindow();
} finally {
disposed = true;
root.clearInterval(timer);
// Observe a pending restore until its bounded check completes. A
// later summon for this owner must cancel cleanup before another
// bridge can take it over and close native again.
if (!restoringOwner) unsubscribe();
else if (!subscription) checkRestore(true);
for (const undo of hooks.reverse()) undo();
}
}
try {
const hookedApis = new Map();
for (const manager of managers) {
const api = manager.m_ownerWindow?.SteamClient?.OpenVR?.Keyboard;
const key = manager.m_Instance?.GetVROverlayKey?.();
if (!ownSteam(key) || typeof api?.Show !== "function" || typeof api?.Hide !== "function" ||
typeof manager.SetVRKeyboardOpenForThisInstance !== "function") continue;
const originals = hookedApis.get(api) || {show: api.Show, hide: api.Hide};
const owner = {manager, api, key, document: editingDocument(manager), focusRefs: new WeakMap(), ...originals};
const d = owner.document;
if (d && typeof d.addEventListener === "function" && typeof d.removeEventListener === "function" &&
typeof manager.SetVirtualKeyboardActiveRef === "function") {
// Steam's shared-context predicate can consult the wrong document.
// Associate a ref only when Steam selects it synchronously inside
// this owner's actual DOM focus/click event. Never infer it from polling.
const focused = event => { owner.focusEvent = event; };
d.addEventListener("focus", focused, true);
d.addEventListener("click", focused, true);
hooks.push(() => {
d.removeEventListener("focus", focused, true);
d.removeEventListener("click", focused, true);
owner.focusEvent = null;
});
hook(manager, "SetVirtualKeyboardActiveRef", original => function(ref, ...args) {
const event = owner.focusEvent, e = event?.target;
if (event?.eventPhase && e === d.activeElement && editableIn(e, d) &&
ref && typeof ref === "object") owner.focusRefs.set(ref, e);
return original.call(this, ref, ...args);
});
}
if (typeof manager.SetVirtualKeyboardShownInternal === "function") {
owner.fieldHook = true;
hook(manager, "SetVirtualKeyboardShownInternal", original => function(open, ...args) {
if (open && targetOf(this))
owner.fieldIntent = {target: targetOf(this), until: now() + REQUEST_MS};
else if (!open) owner.fieldIntent = null;
return original.call(this, open, ...args);
});
}
owner.setOpen = hook(manager, "SetVRKeyboardOpenForThisInstance", original => function(open) {
if (!open && logical?.owner.manager === this) {
// Consume exactly the close reaction caused by this request's
// Hide. Keep acquisition pending if status arrives first.
if (closingNative && expectedClose?.owner === owner &&
expectedClose.request === request && !nativeStatus().open) {
expectedClose = null;
inspect();
return;
}
// Steam has independently closed its field. Never restore it.
releaseLogical(false, false, "steam-closed");
}
return original.call(this, open);
});
owner.keyDown = hook(manager, "HandleVirtualKeyDown", original => function(...args) {
if (logical?.owner.manager === this && fresh()) return;
return original.apply(this, args);
});
owners.push(owner);
if (!hookedApis.has(api)) {
hookedApis.set(api, originals);
hook(api, "Hide", original => function(...args) {
if (logical?.owner.api === this) releaseLogical(false);
return original.apply(this, args);
});
}
}
if (!owners.length) throw Error("steam-target-restoration-unavailable");
for (const name of ["ShowWindow", "BringToFront"]) hook(windowApi, name, original => function(...args) {
try { inspect(); if (blocking()) { interceptedShow = true; return; } }
catch (_) { dispose(); }
return original.apply(this, args);
});
// Ignore every callback argument, including initial field text.
const keyboard = root.SteamClient?.OpenVR?.Keyboard;
if (typeof keyboard?.RegisterForStatus === "function") subscription = keyboard.RegisterForStatus(() => {
try { if (disposed) checkRestore(); else inspect(); } catch (_) { dispose(); }
});
inspect();
timer = root.setInterval(() => {
try {
const time = now();
// Keep the independent 50 ms watchdog. Only an idle full scan
// can be coalesced with a recent helper/event inspection. Lease
// expiry must still remove suppression at the ordinary deadline.
if (!idle() || time < lastClock || (styled && !fresh()) ||
time - lastInspection >= IDLE_INSPECT_MS) inspect();
if (!fresh() && now() - lastStep > 1500) dispose();
} catch (_) { dispose(); }
}, 50);
} catch (error) { dispose(); throw error; }
return {
version: VERSION,
expired() { return !fresh(); },
diagnostics() { return {...snapshot(), route: logical?.route?.kind || null,
nativeOpen: !!native.open, closingNative,
expectedClose: !!expectedClose, session: !!logical, restoring: !!restoringOwner}; },
step(frame) {
if (disposed) return {version: VERSION, unavailable: true};
lastStep = now();
try {
if (Number.isSafeInteger(frame.progress) && frame.progress > progress) {
progress = frame.progress;
lease = frame.ready ? now() + LEASE_MS : 0;
}
inspect();
if (externalRestore && frame.externalRestored === externalRestore.request) externalRestore = null;
if (frame.fallback && frame.fallbackRequest === request) releaseLogical(true);
if (snapshot().eligible && fresh() && frame.ack === request &&
(acknowledged === request || now() < deadline)) {
if (acknowledged !== request) record("acknowledged");
acknowledged = request;
}
inspect();
return snapshot();
} catch (_) { dispose(); return {version: VERSION, unavailable: true}; }
},
send(generation, text, key = false) {
inspect();
if (!snapshot().active || generation !== request || typeof text !== "string" ||
text.length > 8192 || typeof key !== "boolean" || key &&
!["Backspace", "Enter", "Tab", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "VKClose"].includes(text))
return false;
const session = logical, manager = session.owner.manager;
if (session.owner.external) return false;
const previous = manager.m_fnVROnTextEnteredOverride;
if (session.route?.kind !== "callback") {
const ok = sendDom(session, text, key);
inspect();
return ok;
}
const targetText = session.props?.onTextEntered;
if (typeof targetText !== "function") return false;
// The renderer's override can call VRKeyboard.SendText, which requires
// an open native session. Keep Enter/Done handling in the manager, but
// dispatch this one call only to its retained field callback.
const override = value => targetText(value);
manager.m_fnVROnTextEnteredOverride = override;
try {
if (key) session.owner.keyDown.call(manager, text, false);
else targetText(text);
}
finally {
if (manager.m_fnVROnTextEnteredOverride === override)
manager.m_fnVROnTextEnteredOverride = previous;
}
inspect();
return true;
},
dismiss(generation) {
inspect();
if (!snapshot().active || generation !== request) return false;
releaseLogical(false);
inspect();
return true;
},
prepareExternal(generation, key) {
inspect();
const pending = snapshot().externalCandidate;
if (!pending || pending.request !== generation || pending.key !== key) return snapshot();
preparedExternal = {external: true, key, api: keyboardApi, hide: externalHide};
if (handoff && !native.open) begin(preparedExternal);
inspect();
return snapshot();
},
dispose,
};
})
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Build Full Keyboard (TaiKeid's, vendored here: FRAMETOP.md) in the dev container on the
# Frame: build/frametop/framekeyboard, which frametop-keyboard.service runs with --frametop.
# It finds its layouts, themes and Steam bridge beside itself, in build/frametop. Like
# Frametop's other OpenVR programs it loads SteamVR's own libopenvr_api first. Its tests:
# FULL_KEYBOARD_TESTS=1 keyboard/build.sh (ctest in build/frametop).
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
"$root/scripts/sync.sh" >/dev/null
exec "$root/scripts/frame.sh" -C keyboard "set -e
. ../scripts/openvr.sh
tests=${FULL_KEYBOARD_TESTS:-0}
cmake -S . -B build/frametop -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DBUILD_TESTING=\$([ \$tests = 1 ] && echo ON || echo OFF) \
-DOPENVR_LIBRARY=\$openvr_lib/libopenvr_api.so -DOPENVR_RUNTIME_PATH=\$openvr_rpath >/dev/null
cmake --build build/frametop
[ \$tests = 1 ] && (cd build/frametop && XDG_RUNTIME_DIR=\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)} ctest --output-on-failure)
echo 'built build/frametop/framekeyboard'"
+24
View File
@@ -0,0 +1,24 @@
# Development headers and libraries copied from a matching Steam Frame system.
# This directory is supplied by the builder and is never bundled in the repository.
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_SYSROOT "$ENV{FRAMEKEYBOARD_SYSROOT}")
if(NOT IS_ABSOLUTE "${CMAKE_SYSROOT}" OR NOT EXISTS "${CMAKE_SYSROOT}/usr/include")
message(FATAL_ERROR "Set FRAMEKEYBOARD_SYSROOT to an absolute Frame sysroot path; see docs/building.md")
endif()
set(CMAKE_C_COMPILER clang)
set(CMAKE_C_COMPILER_TARGET aarch64-unknown-linux-gnu)
set(CMAKE_C_FLAGS_INIT "--gcc-toolchain=${CMAKE_SYSROOT}/usr")
set(CMAKE_CXX_COMPILER clang++)
set(CMAKE_CXX_COMPILER_TARGET aarch64-unknown-linux-gnu)
set(CMAKE_CXX_FLAGS_INIT "--gcc-toolchain=${CMAKE_SYSROOT}/usr")
set(CMAKE_EXE_LINKER_FLAGS_INIT "-fuse-ld=lld")
set(CMAKE_MODULE_LINKER_FLAGS_INIT "-fuse-ld=lld")
set(CMAKE_FIND_ROOT_PATH "${CMAKE_SYSROOT}")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(ENV{PKG_CONFIG_SYSROOT_DIR} "${CMAKE_SYSROOT}")
set(ENV{PKG_CONFIG_LIBDIR} "${CMAKE_SYSROOT}/usr/lib/pkgconfig:${CMAKE_SYSROOT}/usr/share/pkgconfig")
set(ENV{PKG_CONFIG_PATH} "")
+15
View File
@@ -0,0 +1,15 @@
{
"schema_version": 1,
"active": {
"layout": "en-us-full",
"language": "en-us",
"theme": "graphite"
},
"favorites": [],
"pinned": false,
"num_lock": false,
"hide_numpad": false,
"use_drag_to_move": false,
"auto_leveling": true,
"replace_native_keyboard": false
}
+173
View File
@@ -0,0 +1,173 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Full Keyboard for Steam Frame - Layout Study</title>
<style>
:root { color-scheme: dark; font-family: Inter, "Segoe UI", Arial, sans-serif; background: #171819; color: #f4f4f5; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: radial-gradient(ellipse at 50% 40%, #27292b 0%, #1b1c1e 50%, #151617 100%); }
button, textarea { font: inherit; }
button { -webkit-tap-highlight-color: transparent; }
.page { max-width: 1800px; margin: auto; padding: 46px 38px 30px; }
.viewport { width:100%; position:relative; }
.stage { width:1528px; position:absolute; top:0; left:50%; transform-origin:top center; }
.keyboard { padding:23px 22px; border-radius:20px; background:#0b1016; border:0; box-shadow:none; }
.topline { height:29px; display:flex; align-items:flex-start; justify-content:flex-end; padding:0 3px; }
.indicators { display:flex; align-items:center; gap:24px; color:#a0a4a8; font-size:9px; letter-spacing:1px; }
.indicator { display:flex; align-items:center; gap:7px; }
.led { width:5px; height:5px; background:#62666a; border-radius:50%; box-shadow:inset 0 1px 1px #0006; }
.indicator.on .led { background:#d5e9dd; box-shadow:0 0 7px #d5e9dd65; }
.layout { display:grid; grid-template-columns:94px 900px 174px 234px; gap:26px; }
.macro { padding-top:56px; display:flex; flex-direction:column; gap:15px; border-right:1px solid #17181988; padding-right:15px; margin-right:-2px; }
.macro .key { height:116px; }
.macro .cap { align-items:center; justify-content:center; flex-direction:column; gap:15px; padding:0; }
.macro svg { width:25px; height:25px; stroke:#f5f5f5; fill:none; stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round; opacity:.94; }
.macro .legend { font-size:15px; font-weight:500; letter-spacing:.1px; }
.main { display:flex; flex-direction:column; gap:10px; }
.function-row { height:44px; display:grid; grid-template-columns:repeat(60,1fr); gap:6px; margin-bottom:12px; }
.key-row { display:grid; grid-template-columns:repeat(60,1fr); gap:6px; height:62px; }
.key { display:block; position:relative; min-width:0; height:100%; border:0; border-radius:9px; padding:0; background:transparent; box-shadow:none; color:#fff; cursor:pointer; touch-action:manipulation; user-select:none; }
.key::before { content:""; position:absolute; inset:3px 0 0; border:1px solid #0c1117; border-radius:9px; background:linear-gradient(#141a21,#0c1015); box-shadow:0 2px 2px #0008; transition:box-shadow 80ms ease; }
.cap { position:absolute; inset:0 0 3.5px; display:flex; align-items:center; justify-content:center; border-radius:8px 8px 7px 7px; background:linear-gradient(165deg,#2c333b 0%,#232a32 48%,#1c232a 100%); border:1px solid #52607145; border-bottom-color:#ffffff0c; box-shadow:inset 0 1px 0 #ffffff29,0 1px 1px #0004; transform:translateY(0); transition:transform 80ms ease,background 100ms ease; }
.key:hover .cap { background:linear-gradient(165deg,#3d4855,#303b48 50%,#232a32); }
.key:active::before,.key.pressed::before { box-shadow:0 1px 1px #0004; }
.key:active .cap,.key.pressed .cap { transform:translateY(3px); border-top-color:transparent; box-shadow:none; }
.key:focus-visible { outline:2px solid #e8eef4; outline-offset:3px; z-index:2; }
.legend { font-size:21px; font-weight:450; line-height:1; text-shadow:0 1px 1px #0004; }
.key.utility .legend { font-size:13px; font-weight:500; }
.key.function .legend { font-size:12px; font-weight:500; }
.key.stacked .cap { flex-direction:column; gap:6px; }
.key.stacked .legend { font-size:15px; }
.key.stacked .secondary { font-size:15px; color:#f4f4f5; line-height:1; text-shadow:0 1px 1px #0004; }
.key.align-left .cap { justify-content:flex-start; padding-left:17px; }
.key.align-right .cap { justify-content:flex-end; padding-right:17px; }
.key.modifier .cap::after { content:""; position:absolute; right:10px; top:9px; width:4px; height:4px; border-radius:50%; background:#4f5357; box-shadow:0 1px 0 #ffffff25; }
.key.latched .cap { background:linear-gradient(165deg,#355b7a,#28445c); }
.key.latched .cap::after { background:#e4f8e9; box-shadow:0 0 6px #e4f8e990; }
.key.space .cap::after { content:""; width:60px; height:2px; position:absolute; bottom:11px; border-radius:2px; background:#ffffff20; }
.key.space .legend { font-size:10px; color:#d0d2d5; letter-spacing:2px; }
.navigation,.numpad { display:grid; grid-template-columns:repeat(3,1fr); grid-template-rows:44px repeat(5,62px); gap:10px 6px; padding-top:0; }
.navigation,.numpad { grid-template-rows:44px 2px repeat(5,62px); row-gap:10px; }
.navigation .key,.numpad .key { min-height:0; }
.numpad { grid-template-columns:repeat(4,1fr); }
.key.arrow .legend { font-size:24px; font-weight:400; }
.key.num .legend { font-size:20px; }
.key.num .secondary { font-size:9px; color:#e1e3e5; }
.key.enter .legend { font-weight:600; }
.preview { max-width:850px; margin:43px auto 0; }
.preview-label { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:11px; color:#9c9fa3; font-size:11px; }
.preview-label label { font-size:12px; color:#d7d9dc; }
.preview-label button { background:none; border:0; color:#a8adb2; font-size:11px; cursor:pointer; padding:4px 0 4px 14px; }
.preview-label button:hover { color:#fff; }
textarea { width:100%; min-height:87px; resize:vertical; color:#ebedef; background:#1b1d1f; border:1px solid #3a3d40; border-radius:12px; padding:17px 20px; font-size:16px; line-height:1.6; box-shadow:inset 0 2px 5px #0002; outline:none; }
textarea::placeholder { color:#6e747a; }
textarea:focus { border-color:#777e85; }
@media(max-width:800px) { .page { padding:28px 16px; } .preview { margin-top:28px; } }
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0; }
@media(prefers-reduced-motion:reduce) { * { transition:none!important; } }
</style>
</head>
<body>
<main class="page">
<div class="viewport"><div class="stage"><section class="keyboard" aria-label="Full-size virtual keyboard">
<div class="topline"><div class="indicators"><span class="indicator on" id="num-led"><i class="led"></i>NUM</span><span class="indicator" id="caps-led"><i class="led"></i>CAPS</span><span class="indicator" id="scroll-led"><i class="led"></i>SCROLL</span></div></div>
<div class="layout">
<aside class="macro" aria-label="Clipboard shortcuts">
<button class="key" data-key="Copy" aria-label="Copy selected preview text"><span class="cap"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M15 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/></svg></span></button>
<button class="key" data-key="Paste" aria-label="Paste copied preview text"><span class="cap"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5H6a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><rect x="8" y="2" width="8" height="6" rx="2"/><path d="M8 12h8M8 16h6"/></svg></span></button>
</aside>
<div class="main"><div class="function-row" id="functions"></div><div id="typing" style="display:flex;flex-direction:column;gap:10px"></div></div>
<div class="navigation" id="navigation" aria-label="Navigation keys"></div>
<div class="numpad" id="numpad" aria-label="Number pad"></div>
</div>
</section></div></div>
<section class="preview" aria-label="Keyboard interaction preview">
<div class="preview-label"><span id="modifier-status" aria-live="polite"></span><button id="clear">Clear</button></div>
<textarea id="test-input" spellcheck="false" aria-label="Keyboard preview text"></textarea>
<div id="status" class="sr-only" role="status" aria-live="polite"></div>
</section>
</main>
<script>
const $ = s => document.querySelector(s);
const modifiers = {Shift:false, Control:false, Alt:false};
let caps=false, num=true, scroll=false, clipboard='';
const input=$('#test-input');
function key(label, code=label, units=4, options={}) {
const b=document.createElement('button'); b.type='button'; b.className='key '+(options.className||'');
b.dataset.key=code; b.dataset.base=options.base??label; if(options.shift!==undefined)b.dataset.shift=options.shift;
b.style.gridColumn='span '+units; b.setAttribute('aria-label',options.aria||label);
const cap=document.createElement('span');cap.className='cap';
if(options.secondary!==undefined){b.classList.add('stacked');const secondary=document.createElement('span');secondary.className='secondary';secondary.textContent=options.secondary;cap.append(secondary);}
const legend=document.createElement('span');legend.className='legend';legend.textContent=label;cap.append(legend);b.append(cap);
return b;
}
const fn=$('#functions');
fn.append(key('Esc','Escape',4,{className:'function'}));
const spacer=units=>{const e=document.createElement('span');e.style.gridColumn='span '+units;return e;};
fn.append(spacer(4));
for(let i=1;i<=12;i++){fn.append(key('F'+i,'F'+i,4,{className:'function'}));if(i===4||i===8)fn.append(spacer(2));}
const punctuation=(base,shift,u=4)=>({label:base,code:base,u,opts:{secondary:shift,base,shift}});
const utility=(label,code,u,extra='')=>({label,code,u,opts:{className:'utility '+extra}});
const letter=c=>({label:c,code:c.toLowerCase(),u:4,opts:{base:c.toLowerCase(),shift:c}});
const mod=(label,code,u,extra='')=>utility(label,code,u,'modifier '+extra);
const rows=[
[punctuation('`','~'),...Array.from('1234567890', (n,i)=>punctuation(n,'!@#$%^&*()'[i])),punctuation('-','_'),punctuation('=','+'),utility('Backspace','Backspace',8,'align-right')],
[utility('Tab','Tab',6,'align-left'),...Array.from('QWERTYUIOP',letter),punctuation('[','{'),punctuation(']','}'),punctuation('\\','|',6)],
[mod('Caps Lock','CapsLock',7,'align-left'),...Array.from('ASDFGHJKL',letter),punctuation(';',':'),punctuation("'",'"'),utility('Enter ↵','Enter',9,'align-right enter')],
[mod('Shift','Shift',9,'align-left'),...Array.from('ZXCVBNM',letter),punctuation(',','<'),punctuation('.','>'),punctuation('/','?'),mod('Shift','Shift',11,'align-right')],
[mod('Ctrl','Control',5),{u:5,spacer:true},mod('Alt','Alt',5),{label:'',code:' ',u:25,opts:{className:'space',aria:'Space'}},mod('Alt','Alt',5),{u:5,spacer:true},utility('☰','ContextMenu',5),mod('Ctrl','Control',5)]
];
for(const defs of rows){const row=document.createElement('div');row.className='key-row';for(const d of defs)row.append(d.spacer ? spacer(d.u) : key(d.label,d.code,d.u,d.opts));$('#typing').append(row);}
function placed(parent,label,code,col,row,opts={}){const b=key(label,code,1,opts);b.style.gridColumn=col;b.style.gridRow=row;parent.append(b);return b;}
const nav=$('#navigation');
[['Prt Sc','PrintScreen'],['Scroll','ScrollLock'],['Pause','Pause']].forEach(([l,c],i)=>placed(nav,l,c,i+1,1,{className:'function'}));
[['Insert','Insert',1,3],['Home','Home',2,3],['Pg Up','PageUp',3,3],['Delete','Delete',1,4],['End','End',2,4],['Pg Dn','PageDown',3,4],['↑','ArrowUp',2,6],['←','ArrowLeft',1,7],['↓','ArrowDown',2,7],['→','ArrowRight',3,7]].forEach(([l,c,x,y])=>placed(nav,l,c,x,y,{className:c.startsWith('Arrow')?'arrow':'utility'}));
const pad=$('#numpad');
placed(pad,'Num','NumLock',1,3,{className:'utility modifier'});
placed(pad,'/','NumpadDivide',2,3);placed(pad,'×','NumpadMultiply',3,3);placed(pad,'−','NumpadSubtract',4,3);
[['7','Home',1,4],['8','↑',2,4],['9','Pg Up',3,4],['4','←',1,5],['5','',2,5],['6','→',3,5],['1','End',1,6],['2','↓',2,6],['3','Pg Dn',3,6]].forEach(([n,l,x,y])=>placed(pad,n,'Numpad'+n,x,y,{className:'num',secondary:l}));
placed(pad,'+','NumpadAdd',4,'4 / span 2');placed(pad,'Enter','NumpadEnter',4,'6 / span 2',{className:'utility enter'});placed(pad,'0','Numpad0','1 / span 2',7,{className:'num',secondary:'Ins'});placed(pad,'.','NumpadDecimal',3,7,{className:'num',secondary:'Del'});
const viewport=$('.viewport'),stage=$('.stage');
function fit(){const scale=Math.min(viewport.clientWidth/1528,1);stage.style.transform=`translateX(-50%) scale(${scale})`;viewport.style.height=stage.offsetHeight*scale+'px';}
new ResizeObserver(fit).observe(viewport);fit();
const say=s=>$('#status').textContent=s;
function updateModifiers(){
document.querySelectorAll('.key.modifier').forEach(b=>{const k=b.dataset.key;const on=k==='CapsLock'?caps:k==='NumLock'?num:!!modifiers[k];b.classList.toggle('latched',on);b.setAttribute('aria-pressed',String(on));});
$('#caps-led').classList.toggle('on',caps);$('#num-led').classList.toggle('on',num);$('#scroll-led').classList.toggle('on',scroll);
$('#modifier-status').textContent=Object.keys(modifiers).filter(k=>modifiers[k]).join(' + ')||'';
}
function replace(text,start=input.selectionStart,end=input.selectionEnd){input.setRangeText(text,start,end,'end');input.dispatchEvent(new Event('input',{bubbles:true}));}
function copy(){const selected=input.value.slice(input.selectionStart,input.selectionEnd);if(!selected){say('Select some preview text to copy');return;}clipboard=selected;say('Copied to preview clipboard');}
function paste(){if(!clipboard){say('Preview clipboard is empty');return;}replace(clipboard);say('Pasted');}
function apply(b){
const k=b.dataset.key;const start=input.selectionStart,end=input.selectionEnd;
if(k in modifiers){modifiers[k]=!modifiers[k];updateModifiers();return;}
if(k==='CapsLock'){caps=!caps;updateModifiers();return;}if(k==='NumLock'){num=!num;updateModifiers();return;}if(k==='ScrollLock'){scroll=!scroll;updateModifiers();say('Scroll Lock '+(scroll?'on':'off'));return;}
if(k==='Copy'){copy();return;}if(k==='Paste'){paste();return;}
input.focus({preventScroll:true});
if(modifiers.Control){if(k==='a'){input.select();say('Selected all');}else if(k==='c')copy();else if(k==='v')paste();else if(k==='x'){copy();if(start!==end)replace('');}else say('Ctrl + '+k+' · layout preview');}
else if(modifiers.Alt){say(Object.keys(modifiers).filter(k=>modifiers[k]).join(' + ')+' + '+k+' · layout preview');}
else {
const padChars={NumpadDivide:'/',NumpadMultiply:'*',NumpadSubtract:'-',NumpadAdd:'+',NumpadDecimal:'.'};
if(k==='Enter'||k==='NumpadEnter'){replace('\n');say('Enter ↵');}
else if(k==='Tab'){replace(' ');say('Tab');}
else if(k==='Backspace'){replace('',start===end?Math.max(0,start-1):start,end);say('Backspace');}
else if(k==='Delete'){replace('',start,start===end?Math.min(input.value.length,end+1):end);say('Delete');}
else if(k==='ArrowLeft'||k==='ArrowRight'){const pos=k==='ArrowLeft'?Math.max(0,start-1):Math.min(input.value.length,end+1);input.setSelectionRange(pos,pos);say(k);}
else if(k==='Home'||k==='End'){const pos=k==='Home'?input.value.lastIndexOf('\n',Math.max(0,start-1))+1:(input.value.indexOf('\n',end)<0?input.value.length:input.value.indexOf('\n',end));input.setSelectionRange(pos,pos);say(k);}
else if(/^Numpad[0-9]$/.test(k)){if(num)replace(k.slice(-1));say(num?'Typed '+k.slice(-1):'Num Lock is off');}
else if(k in padChars){replace(padChars[k]);say('Typed '+padChars[k]);}
else if(k.length===1){const upper=/^[a-z]$/.test(k)&&(caps!==modifiers.Shift);const text=upper?k.toUpperCase():modifiers.Shift?(b.dataset.shift??k):k;replace(text);say(k===' '?'Space':'Typed '+text);}
else say(k+' · layout preview');
}
Object.keys(modifiers).forEach(k=>modifiers[k]=false);updateModifiers();
}
$('.keyboard').addEventListener('pointerdown',e=>{const b=e.target.closest('button.key');if(b)e.preventDefault();});
$('.keyboard').addEventListener('click',e=>{const b=e.target.closest('button.key');if(!b)return;b.classList.add('pressed');setTimeout(()=>b.classList.remove('pressed'),130);apply(b);});
$('#clear').onclick=()=>{input.value='';Object.keys(modifiers).forEach(k=>modifiers[k]=false);updateModifiers();say('Cleared');input.focus();};
updateModifiers();
</script>
</body>
</html>
+107
View File
@@ -0,0 +1,107 @@
# Acceptance checks
Use this checklist for a release candidate. Automated core tests and earlier headset checks do not complete every application combination. Fill the matrix for the specific artifact under review; do not infer untested results from the intended use case. Keep private device logs and machine-specific deployment notes outside the public repository.
## Visuals
- Full-size US arrangement, left Copy/Paste, separated navigation and numpad.
- Flat dark grey surface, white legends, rounded grey gradient keycaps.
- Shallow sides; fixed-size caps translate down on press without stretching or exposing a top side.
- Hover, press and modifier state remain distinguishable at actual headset size.
- Displayed legends and pointer hit regions align across supported scaling.
## Input matrix
Run against harmless test content, first in a dedicated receiver, then in Brave. Open the bundled [input check page](input-check.html) in each Brave mode. Its checklist exercises native controls and focus without logging typed characters or reading the clipboard.
Version 0.5.0: automated ARM64 engine tests cover Korean spaces/digits/punctuation and Chinese punctuation/digits, including failed commits. Missing-module/dependency recovery is tested separately. These automated checks do not substitute for the manual application rows below.
On-device report, 2026-09-29, version 0.5.0: the tester completed all six steps of the bundled input check page using the controllers in both KDE Brave and standalone floating Brave. The passes below are user-reported headset results, not inferred from automation.
| Check | KDE Brave | Standalone floating Brave |
| --- | --- | --- |
| Main Enter and numpad Enter deliver key-down/up | Passed | Passed |
| Tab and Shift+Tab change focus correctly | Passed | Passed |
| Ctrl+A, Copy, Paste | Passed | Passed |
| Ctrl/Alt combined with letters on the same backend | Passed | Passed |
| Arrow/navigation/numpad keys | Passed | Passed |
| Focus survives a keyboard pointer click | Passed | Passed |
| No doubled characters from stock keyboard | Passed | Passed |
| Switching apps releases modifiers | Passed | Passed |
The same tester also confirmed typing, Backspace, arrow navigation and Enter in Steam search and KDE Konsole on 0.5.0. These are basic application checks, not a full language-quality or long-session test.
Test a form that submits on Enter and a multiline field that inserts a newline. The keyboard must emit Enter consistently; it cannot force arbitrary websites to implement form submission.
## Configuration, languages and VR switching
- Add a differently sized layout and a custom theme through user JSON files. Reload and select both in VR without rebuilding or restarting.
- Switch layout, language and theme independently, and apply a favorite combination in one step.
- Theme changes affect colors, gradients, geometry of keycaps and state styles while hit regions remain aligned. The approved default remains unchanged.
- Save selection across app restart and an application update; preserve user profile files.
- Reject malformed JSON, duplicate IDs, unsupported schema versions and incompatible language/layout/backend combinations. Keep the last working selection and display a useful error in VR.
- Switch while a modifier is latched or a key is repeating. Release the old mapping and deliver no unintended character to the app.
- Verify English and German output in both Brave modes, including Y/Z, umlauts, Shift, AltGr and shortcuts. Legends must match actual input.
- Check non-Latin font fallback and shaping in the renderer separately from actual language input support.
- Test dead keys, Compose, right-to-left text and IME composition for every profile that advertises them. Unsupported methods must be identified before activation.
- Reload edited profiles and simulate an unwritable settings directory. Preserve the usable runtime state and report failed persistence.
## Lifecycle and recovery
- Full Keyboard launches independently; the existing system keyboard button still opens the stock keyboard.
- Main-view minus/plus icons change width by 5 cm without moving the panel; grabbing handles position and rotation.
- Launching repeatedly retains one owner, restores visibility and recenters it. With replacement disabled, closing and reopening creates a fresh owner; abrupt exit leaves no permanent launch lock.
- Recenter clears position/angle offsets while preserving width, including after a tracking-space change.
- Tracking loss and the 30-second grab timeout restore typing.
- Grabbing with either hand disables horizon correction until release.
- Two controllers holding Caps Lock or Num Lock produce one toggle and one backend down/up pair.
- Closing the dashboard hides Full Keyboard and stops local typing without claiming streamed-app controls.
- Cancelled pointer presses, tracking loss, focus loss and close release keys.
- A crash leaves the stock keyboard usable and does not retain a stale instance lock.
- Input connection loss, runtime restart, sleep/wake, and reboot fail or recover predictably.
- Install/update/rollback/uninstall follow the documented steps and preserve user profiles.
- Reinstall the same production version and manually install an older package. Confirm `current` changes only after validation, the replaced marked release is removed, and unmarked/development directories survive. Refuse manual installation while Full Keyboard holds its instance lock. An acknowledged in-app update removes the replaced marked release and its snapshot; a failed start keeps the old release as `current`. Manual rollback reinstalls a package rather than switching a `previous` link.
- Unsupported runtime interfaces fail without changing Steam files or global input settings.
## Performance and privacy
Measure idle CPU, process memory, redraw time and pointer-to-feedback latency on Frame before setting budgets. Confirm no idle full-frame repaint loop and no texture-replacement flicker.
Typed text and clipboard contents must not appear in logs. Recovery must not paste or replay queued characters into a newly focused application.
## Settings and pin
- Confirm Settings, Recenter, smaller/larger zoom, Pin and language toolbar order. A pinned button changes icon without an active background.
- Turn Num Lock on, close/reopen, and confirm its legends and mapping. Turn it off and repeat.
- In General check Hide numpad and Apply. Confirm the keys disappear and the case becomes narrower, with no laser/grab hits in the trimmed space. Change layouts and cycle favorites; visibility and Num Lock remain global. Show the numpad again and confirm its prior lock state.
- Toggle pin, close/reopen, and confirm the saved icon/state. Neither movement method can move a pinned keyboard; moving/tilting the dashboard still carries it. Unpin and confirm the selected movement method returns.
- Confirm the settings toolbar order is Back, Apply and save, Reload config. Production shows Languages and Layouts and General cards.
- Open each dropdown, scroll through all choices, select one, and verify at most five rows are visible. Click outside a list and confirm only dismissal occurs. Verify laser hit positions match menu rows, including any rows beyond the case border.
- Edit several favorites without saving, then go Back. Reopen and confirm edits were discarded. Repeat with Apply and save and confirm persistence after restart.
- Cycle default plus favorites, including duplicate pairs, a favorite matching the default, and a single matching favorite. Confirm the active theme stays unchanged and cycling does not replace the saved default.
- Hide/reopen the dashboard or disconnect a controller while a menu/scrollbar is held. Confirm captures clear and no stale release selects a new row.
- Run the `settings` test for extra-card horizontal scrolling, independent vertical scrolling, clipping, popup overflow and cancellation. Extra test cards do not ship in the UI.
## Movement behavior
- With Use Drag to Move off, confirm that a white line appears below the unpinned keyboard. Hold the trigger over it, move and release. Verify release after pointing away from the line, two-hand ownership, cancellation on tracking loss and dashboard closure, and saved placement. Grip should not move the keyboard in this mode.
- In General, enable Use Drag to Move and Apply. The line must disappear and grip movement must work as before, including thumbstick depth. Restart and Reload must preserve the preference; Back must discard a pending edit.
- Pin in each mode. Neither trigger-handle nor grip movement should work, and no line should remain visible or intercept the laser. Unpinning must restore only the selected movement method. Repeat with the numpad hidden and while settings is open.
- Compare the movement line with Steam: opaque #3d4450 idle, white while hovered/held, 10% horizontal growth with an 80 ms ease-out transition, and no grown pixels after leaving. Confirm the hit region stays stable and animation stops when idle or pinned.
- With Use Drag to Move off and the keyboard unpinned, show the AUX head cursor, aim at the line, hold AUX, move your head, and release. Confirm that click/typing still works, another controller cannot steal/release capture, and head tracking loss or dashboard closure cancels movement. No haptic pulse should be sent for the head cursor.
- In General, confirm Hide numpad, Use Drag to Move and Auto leveling checkboxes share one vertical column with labels on the right. Auto leveling starts checked on a new or older config. Toggle it and use Back to discard; Apply and restart must retain it. With it off, place the keyboard at a small sideways angle and confirm it stays there after release in laser, AUX and grip modes. Re-enable it and confirm the existing 5°/500 ms correction resumes without a pose jump or pitch change. Reload must refresh the checkbox and behavior.
- With Use Drag to Move off, hold the line with a controller trigger and leave grip released. That same hand's stick up must move the keyboard farther along the ray; down must bring it closer with the existing speed, dead zone and bounds. The other hand's stick must have no effect. Repeat with each controller, release, pin, hide the dashboard and lose tracking to verify depth stops with capture. Ordinary key hover/click must not move depth. An AUX head-cursor capture must ignore both controllers' sticks. Re-enable grip mode and verify its same-hand depth still works.
- Quickly click/release the movement line, then move the controller. A missed overlay mouse-up must not leave the keyboard following for the 30-second timeout. Initial stale rest samples must not cancel a genuine hold; release after an observed hold must stay immediate. Missing/invalid component samples must not count toward a released-sample window. Automated placement tests cover these timing sequences, and the isolated runtime harness verifies the production line-capture wiring.
## Resident native replacement
- Invoke the native keyboard button, then repeatedly click/type on our panel. Native keys, collision and move line must remain absent; neither panel may loop open/closed. X must dismiss ours and keep both panels dismissed. The native button must then reopen ours, and another press must dismiss it. Repeat with automatic Steam field focus and field Enter/close handling. External requests lacking an exact restorable owner must remain native.
- Enable replacement and Apply. Hold keys with both pointers, then X. All owned input releases, our panel hides, the process remains and native does not reappear for that dismissed session.
- Invoke a native keyboard request again. The same resident process shows our panel and completes a new readiness handshake. Repeat after closing/reopening the dashboard.
- Relaunch the app while its resident panel is hidden. The existing process reopens/recenters and Settings is reachable.
- Quit Full Keyboard is present only in Settings while replacement is enabled. Explicit Quit ends the process and restores native fallback; native summons remain usable afterward.
- Uncheck replacement and Apply. The Quit action disappears, normal X exits, and the original Steam button behavior is restored.
+263
View File
@@ -0,0 +1,263 @@
# Architecture
Full Keyboard for Steam Frame is a native, manually launched overlay for dashboard and local-app input. Default behavior leaves the stock keyboard untouched. The optional [native replacement bridge](native-replacement.md) preserves Steam's typing target and restores native presentation independently of the app. It does not route events to streamed VR apps. Internal namespaces, executable names, paths, and overlay IDs remain `framekeyboard`.
## Components
| Component | Responsibility | Implementation |
| --- | --- | --- |
| Configuration | Profile discovery, validation, selection, reload and persistence | Runtime JSON catalog with compiled fallback |
| Layout model | Physical key IDs, bounds, fallback labels and actions | File-defined geometry |
| Language model | Keymap, modifier-level legends, shaping and backend compatibility | Language profiles and XKB legends |
| Key state | Pointer capture, key transitions, modifiers, repeat and cancellation | C++20, independent of renderer/backend |
| Renderer | Draw key faces/sides/legends and animations | Cairo/Pango |
| VR panel | Own overlay, pose, size, pointer events and texture submission | OpenVR + Vulkan |
| VR lifecycle | Coordinate presentation, routing, cancellation and readiness | Host-testable `VrLifecycle` with runtime effect callbacks |
| Input backend | Translate physical actions into press/release events | Gamescope Unicode characters plus native libei controls; physical uinput remains available |
## Rendering choice
Cairo/Pango draw into a CPU image, and the vendored Vulkan texture transport keeps a persistent OpenVR-compatible texture. Provenance and licenses are recorded in [third-party sources](../third_party/README.md).
The renderer draws an image, then updates the GPU texture. The compositor reuses the submitted texture between updates. Short press animations need a bounded redraw loop; idle keys do not require a continuous CPU paint loop. The VR event loop targets a 16.667 ms period while visible, including its processing time, and 250 ms while hidden. A changed replacement session wakes the hidden sleep early so native-close confirmation does not consume another idle period. Identical helper replies keep the original sleep deadline without renewing render-loop progress. Background startup prepares the hidden texture before advertising takeover readiness. It reads the dashboard pose every visible iteration; hidden tracking retries remain limited to 4 Hz. Hidden panels skip native controller-component queries. Stationary, fully aligned panels skip transform updates. These intervals control polling, not compositor presentation; SteamVR presents the existing texture independently.
`VrLifecycle` combines the existing replacement-presentation and tracking policies.
It orders cancellation before route changes, isolates dismissed/fallback requests,
retains pending shows and reports readiness only from runtime observations. The
OpenVR shell supplies effects for App input, overlay presentation and device
cancellation. Geometry, rendering, raw pointer events and independent watchdogs
stay outside the coordinator. [Lifecycle requirements](vr-lifecycle.md) define
private-receiver acceptance scenarios, including actual App holds, queued text
and two-controller ownership. Host tests do not establish physical VR behavior.
The image is retained between paints. Hover/press/release updates clear and redraw only damaged key regions, including overlapping neighboring ink in the original drawing order. Cairo recording surfaces measure ink bounds when the visual model changes; the bounds cover the entire press travel and include text outside the face rectangle. Profile snapshots compare values rather than pointer addresses so Reload cannot reuse stale geometry. Modifier/lock legends, composition/candidates, settings, status, and profile changes use full redraws. Pixel-comparison tests run the same interaction sequence against an always-full renderer.
`PanelTexture` prefers Cairo's native premultiplied BGRA bytes on little-endian systems, using `VK_FORMAT_B8G8R8A8_UNORM` and `VROverlayFlags_IsPremultiplied`. It checks Vulkan format support and OpenVR flag acceptance, and falls back to straight RGBA conversion if setup or submission fails. Rejected native textures stay alive until VR shutdown. Both GPU images still receive full uploads; partial CPU painting does not introduce stale alternate textures. The visible 60 Hz pose loop and 80 ms Graphite animation duration are unchanged.
The HTML in `design/` is a keycap design artifact only. Settings uses native card geometry rather than that older HTML toolbar. It is not an embedded browser requirement for the native app.
## Configuration and selection
Global pin, Num Lock and numpad visibility preferences live beside the active selection and favorites. Hidden numpad keys are excluded from painting and hit testing. The remaining geometry keeps its full-size scale, centered in a narrower case. The fixed texture and saved dashboard anchor remain centered, with transparent side space excluded by the OpenVR intersection mask. Composition controls fit the narrower case; settings cards use the full case width.
[Configuration](configuration.md) defines independent layout, language and theme profiles. Native rendering consumes the selected model, not hardcoded US keys or graphite colors. The VR settings panel uses the same catalog and validation as the host preview. Prepare a complete candidate before activation, release keys using the old mapping, and atomically replace geometry and hit regions. Persist the selection only after successful activation.
The language model validates required physical keys. The normal Frame path composes characters locally and sends Unicode. Only physical uinput and external JIS use an explicit `--target-language` declaration. Derived legends must match the symbols actually delivered. Locale names alone do not establish language support. Keep IME/composition and UI translation separate from physical key geometry.
Settings geometry and scrolling live in `SettingsUi`. App supplies three data-defined cards for languages/layouts, general preferences and experimental native replacement; the same model accepts extra cards and fields in tests. Card and field clips are shared with control hit testing. An open dropdown owns clicks and scroll input; outside dismissal consumes its click. Scroll changes invalidate pressed controls, and cancellation clears scrollbar ownership and menus.
Pointer targets distinguish keyboard keys from UI controls. Their hover sets are also separate in the renderer, so a custom key ID can match a control ID without activating or highlighting that control. A control press activates only when released over the same control target.
Controls carry typed actions with candidate indices or selected profile IDs as
payloads. A press captures that action alongside its visual identity; release
dispatches it without parsing an ID string. `Options` uses enums for operation
and input backend, with textual names confined to command-line parsing.
The logical case remains 1600 x 600. Cairo and the Vulkan upload include 200 transparent pixels above and below it so five-row dropdowns can escape the case. Preview and OpenVR events convert texture coordinates back to case coordinates. The symmetric margins preserve the case center, physical width and dashboard placement math. OpenVR intersection masks admit the case plus the open dropdown, excluding unused transparent margins. PNG exports include a visible movement line below the case; popup captures include both margins.
Favorites use layout/language identity. App keeps the saved default separate from the current runtime selection so cycling and immediate pin saves do not overwrite it. Apply and save persists profiles, favorites and pin state atomically while preserving unknown top-level fields. Profile preparation happens before input cancellation or active-state changes. The optional `use_drag_to_move` preference defaults to false. In that mode a white line below the case starts movement with the laser trigger. Enabling it selects the existing grip movement and hides the line. The line shares the panel texture and has its own fixed intersection-mask rectangle. Its idle color is native `#3d4450`; hover/capture interpolates to white and 1.1 times the width over 80 ms using CSS ease-out. The renderer schedules paints only until that transition completes, including when settings is open. The larger stroke remains inside the same padded hit region. Capture belongs to one tracked head/controller device and cursor; mouse-up, tracking loss, dashboard hiding and focus changes release it. Frame trigger-component samples also recover a release missed by pointer events. After observing a held trigger, release is detected immediately. If a quick click ends before the first physical poll, `TriggerReleaseGuard` allows 150 ms for stale initial samples, then requires 50 ms of consecutive released samples. Missing/invalid data or a gap over 100 ms restarts that released-sample window. Every new capture resets the guard; AUX and grip movement retain their separate release paths. Pin gates both movement methods and removes the line in the VR layer; dashboard following continues through the existing placement path.
Preference snapshots go to a dedicated `SettingsWriter` worker. Atomic file writes and fsync never run during key-down or pin clicks. One writer preserves ordering and coalesces pending snapshots to the newest state; completion errors return to App during its normal tick. Shutdown drains the final write after key cleanup. Reload defers to a later tick while writes or startup reconciliation are pending, keeping the VR loop running. Repeated requests coalesce, and any newer saves also finish before Reload reads configuration, so an older save cannot overwrite the reloaded file later. Profile preparation still runs on App's thread once storage is idle. Deferred preparation failures return through the normal UI status without automatic retries; save failures remain visible.
The VR writer commits replacement/startup flags only after unit configuration succeeds. A unit failure saves the other snapshot fields while preserving the two prior saved flags, and returns a startup warning separately from storage errors. A JSON write failure still rolls the unit transaction back and reports unsaved preferences. Reconciliation remains a separate operation that never writes JSON.
VR startup queues one reconciliation of valid saved startup preferences on that worker. This refreshes owned service templates after an update without requiring Apply or Reload. Matching files skip manager reload; malformed settings preserve the existing unit. Previews never configure startup.
Deliberate VR exits return 0 for manual launches and 3 for boot launches or processes carrying `FRAMEKEYBOARD_SERVICE`. The boot flag covers older units without the marker; the inherited marker covers service-managed update successors that omit the boot flag. Runtime shutdown and watchdog SIGTERM remain eligible for service restart.
`UpdateStartup` captures background-update intent from a valid inherited acknowledgement socket and a nonempty service marker before consuming the descriptor environment. Main passes that intent separately from the boot-only `--autostart` flag. With replacement enabled, App uses it for hidden presentation without rechecking boot opt-in; without replacement it opens normally. Prepared-update shutdown explicitly disposes the bridge without restoration and cancels the exact-overlay guard. Default stop and EOF still restore native. The helper drops queued input before applying the stop disposition, and the replacement destructor waits for helper cleanup before update activation.
## Input and focus
`OutputController` owns the enabled gate, ordered output queue, delivered-key
references, text retry buffer and repeat scheduling. `KeyboardState` retains
pointer captures, visual state and sticky modifier interpretation. Each accepted
native press snapshots its chord; Unicode presses snapshot the composed text.
Up to 32 output entries can wait for delivery, with at most 32 Unicode characters
per text entry. Accepted queued keys receive the normal pressed visual and haptic.
Releases do not require queue space. A queued click sends a balanced down/up;
already-delivered keys release immediately, including while another result waits.
Repeats begin only after initial delivery completes and never catch up after a stall.
The controller consumes text and native-control results before advancing to the
next entry. Steam named controls acknowledge delivery through the helper; local
native controls complete when submitted to libei/uinput. Physical holds must end
before a following Unicode commit. A failure stops the queue and releases delivered
holds. The failed text and consecutive queued text before the first native action
remain for one explicit Enter retry, in transport-sized chunks. Native actions and
text after that boundary are discarded with a status, so a discarded Tab or Enter
cannot join text intended for separate fields. A synchronous preedit rejection
leaves the original editor responsible for its unchanged preedit.
Cancellation, hide, input reset and observed target/request or route changes clear
queued output and retries before another session can receive input. A late result
cannot advance the new session. An unowned Wayland ordering barrier left by
cancellation still rejects output presses with a compositor-wait status; local
locks and preedit edits remain available. Transport-specific protocol state and
device cleanup stay in the sinks. No queue state is persisted or logged.
The installed launcher uses the pinned libei client through the running Gamescope socket. The client is compiled into the app because SteamOS libei 1.4.1 can crash on an aborted negotiation; system libraries remain untouched. A keyboard seat is bound, output starts only after device resume, and a pause or removed device clears held state. Resuming or receiving a replacement keyboard can restore typing. Disconnected native and Unicode sockets reconnect in the background with a 1-second delay doubling up to 30 seconds, without replaying old holds or text. While waiting, App reports "Keyboard paused or disconnected". An interruption flag survives a pause/resume pair within one dispatch, and blocks backend output until the app clears its old UI holds. Connection state is tracked even while the dashboard disables typing. Existing evdev key mapping is shared with uinput; compositor repeat replaces synthetic EV_KEY repeat in the libei path. Development previews remain input-disabled. Apply/Reload releases held keys and restores typing only for a live backend, available text transport and an explicitly enabled launch. Physical-only modes additionally check their target declaration; no pause/resume button is needed.
Use logical names such as `Enter`, `ControlLeft`, `KeyC`, and `NumpadEnter`. Every backend owns its mapping. Steam key-state values are not Linux evdev values. The native keyboard must not use Steam's contextual Enter callback for its dedicated Enter action.
uinput follows the compositor's input routing; it does not address a chosen window. A target overlay ID does not prove keyboard focus. Preserve focus while interacting with the panel and verify delivery in KDE and the outer floating-window session separately.
Copy/Paste are shortcut actions, initially Ctrl+C and Ctrl+V for browser targets. They do not read passwords or clipboard contents. Terminal shortcuts and clipboard sharing across sessions are separate requirements.
## Packaging and failure cleanup
Releases live under `~/.local/share/framekeyboard/releases`, with a `current` symlink and a launcher in `~/.local/bin`. Config lives under `~/.config/framekeyboard`. The package enables no startup by default. The explicit Start with SteamOS option maintains a user-local session unit on the preference worker. It launches hidden replacement only when both saved options are on; independent fallback and update helpers survive main-process handoff. No kernel module or Steam patch is installed.
Production replacement stages a unique new directory, including for a same-version
reinstall, and atomically switches `current`. The installer holds the instance lock
throughout, so no running keyboard can lose its release files. Installer and updater
share root/name-bound production markers and conservative deletion checks. Only the
replaced marked release is removed, never `current`, development deployments or
unmarked historical releases. In-app updates retain the old release and settings
snapshot through startup probation and service adoption; success removes them,
while failure restores the old release. Cleanup errors cannot roll back a confirmed
app. No permanent `previous` link is maintained; manual rollback reinstalls an older
package. Failed-update preference snapshots remain available for recovery.
Every held key has a cancellation path. Disconnect, focus loss, hiding, close, and drag capture release input. Overlapping controller presses share backend key references, including lock-key toggles. After a drag timeout or tracking loss, cleanup clears the application's dragging state even if the motion tracker already stopped. Cleanup is idempotent.
Optional replacement uses independent readiness leases and watchdogs to restore the stock keyboard. Per-user instance IPC accepts only a recenter request, not arbitrary input or text.
## Instance ownership and placement
VR launches acquire `VrInstance` before touching OpenVR. A later launch connects to the owner's private local socket, sends only a recenter request, waits for acknowledgment, and exits. The render loop handles requests on its own thread; it releases keys, returns to the keyboard view, resets placement beneath the current dashboard, and shows its own overlay. This avoids duplicate overlay keys and avoids reassigning SteamVR's generated application PID through a second VR connection.
`PanelPlacement` contains pure transform math with no VR calls. Its initial width is 0.95 m; recenter keeps the chosen width and applies a 50-degree backward pitch from upright, independent of headset pitch/roll. Recenter reads the live bottom center of `valve.steam.gamepadui.main` through `GetTransformForOverlayCoordinates`, normalizes its scaled basis, and puts the keyboard's top edge 6 cm below it with its center 24 cm toward the viewer. The hidden stock keyboard is deliberately ignored because its transform can lag behind dashboard movement. If the dashboard pose is unavailable, placement falls back to 0.85 m ahead and 0.65 m below the headset. While visible, dashboard movement updates the panel's relative pose at a target 60 Hz without repainting the texture. During a grab the hand owns the pose and updates its offset. The last dashboard anchor is saved with placement, so close/reopen follows subsequent dashboard movement. `PanelPlacement` stores the world transform directly; only recenter constructs a default rotation. The main-view resize icons queue bounded width changes without rebuilding the pose; controller grabbing handles positioning. The VR loop consumes them, updates the transform/width, and checks compositor visibility independently of its cached state. Tracking loss clears held keys; a valid pose after a brief loss restores visibility without moving the panel. Tracking-origin reset events still request recentering. `placement_store.cpp` validates and atomically persists the standing-space transform, width and tracking-universe ID on orderly shutdown. A cold launch restores the saved placement once tracking is valid. A different known universe recenters the pose while preserving saved width. An invalid file falls back to defaults. Legacy placement files without a dashboard anchor retain their world-space pose until reset. This is not a cross-room spatial anchor.
`IsDashboardVisible` gates both rendering and interaction. The loop checks it before pointer events and before repeat, releases held keys before disabling `OutputController`, cancels preedit/grabs and drains hidden pointer events. The gate also covers Apply, Reload and relaunch, so they cannot enable hidden output. The overlay does not set `MakeOverlaysInteractiveIfVisible`; it relies on dashboard interaction only and cannot deliberately claim scene inputs after closure. No active-VR-app backend is provided, so the panel remains hidden outside the dashboard.
Controller line dragging and grip dragging capture a controller-to-panel transform at grab time, then composes each tracked controller pose with that fixed offset. Frame's dashboard masks modern grip actions and legacy controller-state polling. `GrabInput` instead reads the native `button_grip` render component for the known Frame controller models. A synthetic all-released controller state supplies its neutral component pose; the live component's relative rotation measures physical squeeze. `GripLatch` applies press/release hysteresis and requires a released sample after startup or a missing sample. This model-specific path must be rechecked when Valve changes controller models. It does not alter runtime input settings.
Laser hover gates a grab, and releasing grip, losing input/tracking, hiding or recentering cancels it. Only the captured controller owns movement; leaving the hover target during a drag does not release it. The UI cancels held keys and suppresses typing while dragging. The keyboard opens one OpenVR connection after checking that the VR server is already running.
`App::down` and `App::up` report accepted key press/release transitions. `App::move` reports entering a different key when that pointer is not holding a key. Only the VR layer sends feedback: 25 ms/full amplitude for press and 8 ms/0.35 amplitude for release, both at 150 Hz, 4 ms at 240 Hz/0.1 amplitude for hover. Clicks take priority over hover in the same frame. This keeps haptics out of rendering and input delivery; duplicate/rejected events, stationary hover, dragging and repeat do not pulse.
During a grab, `GrabInput` also reads the native thumbstick render component. `ComponentAxis` calibrates neutral/full-up rotations using synthetic axis states. It extracts the signed twist around that axis, excluding sideways tilt. The controller model’s tip supplies the laser direction. `PanelDrag` integrates only the captured controller’s vertical stick value into its relative translation with a dead zone, elapsed-time cap and depth bounds. This keeps rotation, size and the lateral grab offset intact. Grab input uses neither action manifests nor global input overrides.
The optional boolean `auto_leveling` defaults to true and is exposed in General settings. `HorizonAlignment` bypasses correction when it is disabled. Applying or reloading a change adopts the displayed pose as the raw placement and resets the easing clock, so disabling cannot expose an older raw roll and enabling starts smoothly. `HorizonAlignment` corrects only local roll within 5 degrees of the standing-space horizon, using 500 ms smoothstep easing. It preserves the panel normal and center. The VR loop keeps raw placement separate from displayed placement so corrections never accumulate in the grab transform. Grabs capture the displayed pose and bypass horizon correction entirely. Normal grip release starts a fresh 500 ms animation, including when the hand was stationary before release. Leaving the range eases the correction out. Cancellation freezes the visible pose, recenter resets assistance, and shutdown saves the displayed pose. Panels facing straight up/down skip alignment because horizon roll is undefined.
`KeyHaptics` retains the controller owning each accepted pointer press and sends release feedback to that owner. Each controller has its own feedback queue; clicks take priority over hover, and hover is suppressed until a click ends. Release uses a shorter pulse because equal API values felt stronger on release on Frame. `VrHaptics` uses an output-only action manifest and `TriggerHapticVibrationAction` restricted to the event controller’s hand source. Missing/unknown controllers receive no pulse; there is no fallback to the overlay-wide laser haptic API. The haptic action set binds no controller inputs and changes no global input settings.
When an app tab such as Brave is active, Steam's hidden main dashboard overlay retains a stale transform. `DashboardAnchor` uses the main tab only while visible; the visible dashboard bar then carries that mount with its full translation and rotation across all tabs. Overlay scale is removed without flattening pitch or roll. The bar reference remains continuous until recentering to avoid orientation jumps on tab switches. Both anchor and bar pose are saved so reopening directly into an app preserves the offset. Without a prior calibration, initial placement falls back beneath the live bar. Invisible overlays are never accepted as current poses.
## Japanese composition
`JapaneseComposer` owns a bounded in-memory preedit, romaji parsing and UTF-8 kana editing. Anthy is loaded dynamically on first conversion. Its public C API provides segment candidates; no dictionary learning/commit API is called. Candidate controls, script changes and Enter update local state before committing. Composition keys use `KeyboardState` for pointer ownership, visuals, modifier consumption and haptics, with native emission/repeat suppressed for those presses. Raw Ctrl/Alt/Meta shortcuts retain their normal key path, but unfinished composition is queued before native key-down. Once accepted, the controller owns that text, allowing the next preedit to begin while delivery waits. A synchronous rejection retains the preedit and blocks the forwarding action. Gamescope text commits use an asynchronous sync callback before a following key can be sent over the separate libei socket.
`EiSink` derives the Japanese text socket only from the documented `gamescope-N-ei` naming convention, preserving its directory and compositor number. Nonstandard key sockets require an explicit `--text-socket`; there is no fallback to gamescope-0 or the default Wayland display.
`GamescopeText` uses the pinned, generated version-1 input-method protocol. It feature-checks the manager/seat and handles unavailable connections. Discovery and commits progress through nonblocking flush/dispatch. Each handshake stage and commit has a one-second deadline. A commit timeout reports failure without killing the connection. Its ordering barrier remains until the late callback arrives, including after cancellation; native releases still pass. No timed-out text is automatically resubmitted. The protocol cannot establish whether the target received text before a timeout, so an explicit retry can duplicate text. `EiSink` retries failed connections after 1, 2, 4 seconds up to 30 seconds, resets the delay on readiness, refuses text while native keys are held, and does not read target fields or clipboard data. Text and physical output share explicit launch gating. Commits are limited to 32 Unicode characters to stay below the compositor's temporary keymap capacity; local reading is limited to roughly 30 characters.
The renderer reserves space for preedit/candidates during Japanese, Chinese and Korean composition. Kana/shift labels come from the selected language profile. Settings, profile changes, target-focus loss, hiding, dragging and recentering discard unfinished composition. Laser FocusLeave releases only the identified controller's keys and retains composition because it does not change application focus. Failed text submission keeps the preedit for recovery. The protocol reports submission availability, not application acceptance; application coverage needs separate tests.
## Code map
| Area | Files |
| --- | --- |
| Configuration and validation | `src/config.cpp`, `include/framekeyboard/config.hpp` |
| Pointer/key ownership, XKB legends, uinput | `src/input.cpp` |
| Output queue, gate, delivered holds, retries and repeat | `src/output.cpp`, `include/framekeyboard/output.hpp` |
| Compositor key and text delivery | `src/ei_input.cpp`, `src/text_input.cpp` |
| Native rendering and hit regions | `src/panel.cpp` |
| Settings, profile changes and composition dispatch | `src/app.cpp`, `src/settings_ui.cpp` |
| Japanese reading and conversion | `src/japanese.cpp` |
| Preview and command-line entry | `src/preview.cpp`, `src/main.cpp` |
| Instance IPC | `src/instance.cpp` |
| Placement math and persistence | `src/placement.cpp`, `src/placement_store.cpp` |
| Grip model and haptic state | `src/grip.cpp`, `include/framekeyboard/feedback.hpp`, `include/framekeyboard/vr_haptics.hpp` |
| OpenVR lifecycle and event loop | `src/vr.cpp` |
| Tests and opt-in receiver probes | `tests/` |
## Chinese and Korean composition
`CjkComposer` loads a bundled engine adapter on demand with `dlopen`. The
main executable has no PyZy/libhangul startup dependency. Each adapter links its
own system library and exposes a versioned factory through a private interface.
Adapters and host are built together; engine objects are destroyed before their
module is unloaded. A missing engine rejects Apply, or falls back to English at
startup without overwriting the saved profile.
The engine keeps bounded Pinyin or two-set Hangul state. PyZy supplies Chinese
candidates and script conversion; libhangul supplies Korean syllable assembly.
The app releases physical holds and gates commits using the same transport rules
as Japanese. Failed commits retain preedit. Space or a candidate selection commits
a completed Pinyin reading; Hangul word boundaries commit before forwarding keys.
Profile Apply prepares engines before changing the active model. Missing build
support or dictionaries leaves the prior profile usable.
PyZy's learning methods are not called. Candidate selection reads its conversion
and remaining phonetic text, saves local undo state, and rebuilds the remaining
reading. Its user-cache/config paths are non-directories, so the library cannot
read or persist personal history. Korean Backspace rebuilds from the bounded key
sequence, preserving jamo-level undo across resyllabification. Neither engine
changes the system IBus selection. See [language setup](languages.md).
## Independent Unicode character output
`LanguageMap::symbol` resolves the selected XKB profile without legend overrides.
Its compose state handles dead accents locally. `App` sends printable characters
through the Gamescope text connection and retains a failed commit for retry.
Local Caps/Num toggles share pointer ownership without changing compositor locks.
Text repeat is bounded and canceled with its pointer; native controls keep the
existing backend repeat path. Numpad navigation is sent as explicit Home/arrows/etc.
`EiSink` reads the advertised keymap once per keyboard device and tracks group
updates. Ctrl/Alt letter shortcuts and dedicated Copy/Paste search that map for
the intended Latin symbol, with a conventional physical fallback where metadata
is absent. This lookup neither changes the system keymap nor reads app text.
After a libei disconnect, `EiSink` clears held keys and reports one input reset.
Reconnect uses nonblocking Unix sockets and frame-by-frame negotiation with a
three-second deadline, retrying after 1, 2, 4 seconds up to 30 seconds. A ready
keyboard resets the backoff. Reconnection also recreates the Unicode transport;
old holds and text are never replayed. The initial launch alone may wait up to
three seconds for the first keyboard.
AUX head-cursor clicks use the existing OpenVR overlay mouse events. Laser movement accepts tracked HMD and controller poses. Grip reads and haptic output remain controller-specific. Device/cursor ownership prevents a controller reusing cursor zero from releasing an HMD capture. Tracking loss and dashboard hiding cancel either source. Handle-click logs contain source metadata only, with no key coordinates or typed text.
While a controller owns a laser-line capture, `GrabInput` reads only that controller's thumbstick without querying or requiring grip. It supplies the calibrated tip ray at capture so depth follows the same laser direction as grip movement. The laser path polls no render components while idle; an AUX/HMD capture has no stick source and cannot borrow a controller's axis. Missing stick/model data returns zero depth while tracked pose movement remains available.
## Optional native keyboard replacement
`NativeReplacement` wraps the local sink and owns a temporary Python helper over a private socketpair. The VR thread exchanges bounded, nonblocking packets. The helper talks only to Steam's localhost CDP endpoint; SharedJSContext owns presentation hooks and a 900 ms lease. Only completed VR-loop iterations can renew that lease. Each status/target generation also requires a visible-panel acknowledgement within 400 ms. A separate VR watchdog hides our panel if the main loop stalls. The helper recovers a main loop that stops progressing even if the watchdog stays alive. Explicit profile/engine preparation and settings barriers have bounded slow-work deadlines. Main-thread runtime-call scopes and system-resume detection provide separate bounded grace periods, including when SteamVR blocks both threads. Independent watchdog traffic cannot renew takeover readiness or extend those deadlines. See [recovery timing](native-replacement.md#presentation-and-fallback).
The CDP client connects to Steam's existing `127.0.0.1:8080` endpoint, bypasses HTTP proxies and rejects redirects. It opens no debugging listener. SteamOS's optional port 8081 forwarder serves remote debugging clients and is unused by this connection. The bridge checks Linux's TCP listener tables before connecting and periodically while active. It rejects replacement if a listener on 8080 is bound outside loopback, or if the checks cannot run. Port 8081 is not an availability gate. These checks do not configure ports or services. Missing or rejected debugger access leaves replacement inactive and native input available.
Supported Steam sessions deliver Unicode and plain Enter/Backspace/Tab/arrows through a retained field callback or the virtual-keyboard manager's owning document. The bridge closes native presentation completely and preserves only the corresponding manager's logical open state and typing target. It accepts requests only when that owner's provider can restore the same Steam target independently. Minimal Frame-local Gamescope requests use the local backend after an independent native guard resolves the exact overlay and arms recovery. Other external modes, buffered, mismatched and unknown requests stay native. Steam sessions block unsupported physical controls and shortcuts so they cannot reach another local app; local-app sessions retain libei routing. Asynchronous text commits are retained until acknowledged; a failed commit stays available for explicit retry, while cancellation invalidates late acknowledgements. Runtime details, compatibility and acceptance checks are in [native replacement](native-replacement.md).
Steam field takeover requires a direct callback or a connected editable input
in the manager's own document, validated by Steam's current or last field ref.
When Steam's validity predicate checks the wrong document, a ref selected
synchronously during the owner's DOM focus or click event establishes the association.
Polling or an unrelated ref change cannot establish it.
DOM edits and caret operations stay in that document without reading its text
or relying on compositor focus. Target loss ends the session; target changes
invalidate queued input. A dashboard button request without a field may show
the panel, but plain text has no recipient. The global controller-text route
is never used. Literal text and named controls are distinct IPC operations.
`ReplacementPresentation` separates resident panel visibility from process lifetime. In VR replacement mode, X releases input and emits a request-scoped dismissal that clears the retained target without reopening native presentation. Only semantic target/open changes create new generations; duplicate provider callbacks cannot create a reopen loop. A supported native keyboard button toggles an acknowledged panel off across owners, including after returning from a closed app. Explicit Steam field shows are tracked through the manager and transfer the target instead. The helper and lease remain active while hidden. A later eligible request follows the normal visible-panel readiness handshake. Explicit Settings Quit, signals and runtime shutdown still exit and withdraw replacement. Ordinary mode and desktop preview retain X-to-exit.
Losing a Steam field ends its replacement session and hides the panel. It never
authorizes a switch to compositor input, because field loss does not establish
local focus. The new request generation cancels old output and holds before a
later summon or field focus can acquire another session.
Dashboard frame selection is separate from DOM field focus. A hidden Steam
document cannot remain an input owner. A selected-frame change invalidates the
old request before another dispatch. While the panel is open, it follows a
supported Steam frame or a local frame whose exact overlay was acquired through
a native request earlier in this bridge lifetime. The map is bounded to 16
frames. Local handoff needs a fresh independent guard acknowledgement and an
OpenVR active-dashboard-overlay check; its recovery also rejects abandoned
targets. Unknown frames close the old session. X, fallback and expiry do not
create new handoffs.
## Manual release updates
`Updates` owns a private Python child and polls bounded nonblocking status packets. Settings retains the offer in process memory and resets the latest-version message after five seconds. `update/update_helper.py` verifies fixed-repository HTTPS assets, SHA-256, safe archive paths, AArch64 binaries and copied profiles before requesting restart. `main` destroys App, VR/input/replacement and the instance owner before sending activation. The helper switches versioned links under the installation lock, launches the new build with preserved arguments and waits for three seconds of completed-loop progress through a private startup socket. Startup failure restores and relaunches the prior release. A service-managed original process remains alive after cleanup until the helper verifies the successor has become systemd's MainPID; rollback transfers ownership the same way. Manual launches use the existing parent-exit protocol. Downloads and validation never run on the VR thread. See [updates](updates.md).
+142
View File
@@ -0,0 +1,142 @@
# Build from source
Most users should install the ARM64 archive described in the [README](../README.md). These instructions are for development and release packaging. Build on a Linux development machine; a host x86-64 binary cannot run on Frame.
## Dependencies
Install C and C++20 compilers, CMake 3.24+, Ninja, Python 3, pkg-config, and development headers/libraries for Cairo, Pango/PangoCairo, libxkbcommon, json-c, Wayland, SDL2, and Vulkan. The libei client is pinned and built statically from `third_party/libei`; normal builds need neither system libei nor network access. The build uses the host `wayland-scanner`. Tests also need X11 development files. Chinese/Korean release builds also need PyZy development headers/library and libhangul. Frame supplies both and the PyZy system dictionary; include their libraries in a curated sysroot. Host builds may omit these engines, but then cannot activate their composition profiles. Runtime adapters are loaded on demand from the release's `lib/framekeyboard/` directory; they are built alongside development executables in `engines/`. Optional Japanese conversion tests require Anthy and its dictionary; Japanese rendering needs a font such as Noto Sans CJK JP.
The OpenVR header is vendored, but `libopenvr_api.so` comes from an installed SteamVR runtime or a separately supplied SDK library. The build searches common Linux Steam paths. Override discovery with `-DOPENVR_LIBRARY=/absolute/path/to/libopenvr_api.so` when configuring CMake.
```sh
git clone https://github.com/TaiKeid/steam-frame-full-keyboard.git
cd steam-frame-full-keyboard
./scripts/build.sh host
ctest --test-dir build/host --output-on-failure
./build/host/framekeyboard --preview
./build/host/framekeyboard --check
```
With a custom OpenVR path, configure before running the build script:
```sh
cmake --preset host -DOPENVR_LIBRARY=/absolute/path/to/libopenvr_api.so
```
CTest reports unavailable `korean-input` and `chinese-input` suites as **Skipped**, not passed. For release validation on Frame, set `FRAMEKEYBOARD_TEST_CJK_REQUIRED=1`; unavailable engines then fail. Copy `engines/` beside transferred test executables. Run `python3 tests/engine_loading_tests.py build/host/language-tests` to check missing-module recovery in isolated copies.
The preview uses the same renderer, key state, and settings as VR but never injects OS input. `scripts/preview.sh` serves the older HTML layout study, not the application.
## ARM64 sysroot
The repository includes `cmake/steam-frame-aarch64.cmake`. It uses host Clang/LLD and target headers, GCC C++ runtime files, and libraries copied from a matching Frame system. A sysroot is not included in Git or the release archive. Recheck compatibility after Frame updates.
Set `FRAMEKEYBOARD_SYSROOT` to an absolute path outside the source checkout. It must contain:
- `usr/include`, including the target C++ standard library headers.
- `usr/lib`, with target libraries, linker names/scripts, GCC runtime files, and C runtime objects such as `Scrt1.o`, `crti.o`, and `crtn.o`.
- Both `usr/lib/pkgconfig` and `usr/share/pkgconfig`, plus library-specific headers such as `usr/lib/glib-2.0/include`.
- `opt/steamvr/bin/linuxarm64/libopenvr_api.so`.
If SSH access and rsync are configured on your Frame, this is one way to copy those installed files. Run these commands **on the development host**, replacing the SSH address with your device's address:
```sh
export FRAME_HOST=steamos@steam-frame
export FRAMEKEYBOARD_SYSROOT="$HOME/steam-frame-sysroot"
mkdir -p "$FRAMEKEYBOARD_SYSROOT/usr" "$FRAMEKEYBOARD_SYSROOT/usr/share" \
"$FRAMEKEYBOARD_SYSROOT/opt/steamvr/bin/linuxarm64"
rsync -aL "$FRAME_HOST:/usr/include/" "$FRAMEKEYBOARD_SYSROOT/usr/include/"
rsync -aL --exclude=modules --exclude=firmware --exclude=debug \
"$FRAME_HOST:/usr/lib/" "$FRAMEKEYBOARD_SYSROOT/usr/lib/"
rsync -aL "$FRAME_HOST:/usr/share/pkgconfig/" "$FRAMEKEYBOARD_SYSROOT/usr/share/pkgconfig/"
rsync -aL "$FRAME_HOST:/opt/steamvr/bin/linuxarm64/libopenvr_api.so" \
"$FRAMEKEYBOARD_SYSROOT/opt/steamvr/bin/linuxarm64/"
```
Copying most of `/usr/lib` can take several gigabytes. These commands only read the device; they do not install packages or unlock SteamOS. They assume the Frame already contains the required development files. A missing header, linker name, or pkg-config file must be supplied from a matching target development environment. Do not substitute host x86-64 libraries. The initial validated sysroot was a smaller curated copy of these files; the broad copy recipe above has not been independently tested from a clean device.
## Cross-build and package
```sh
export FRAMEKEYBOARD_SYSROOT=/absolute/path/to/frame-sysroot
./scripts/build.sh frame-arm64
file build/frame-arm64/framekeyboard
./scripts/package.sh
```
The generated Wayland protocol is compiled as C. GCC and Clang builds use the same warning flags; check clean builds of both before claiming a warning-free release.
The package script builds ARM64, installs into `out/framekeyboard-VERSION-aarch64/`, includes docs/licenses/profiles, and writes an archive plus `out/SHA256SUMS`. Only the packaged executable is stripped, using the cross toolchain's `CMAKE_STRIP`; the build-directory executable retains debugging information. Packaging rejects remaining debug/static-symbol sections, a non-AArch64 binary, or embedded source/sysroot/builder-home paths. Compiler prefix maps remove build paths from diagnostics, and haptic manifests are found relative to the executable rather than through an embedded source path. Development builds copy the manifests into `build/<preset>/vr/`.
The binary retains the device OpenVR runtime path `/opt/steamvr/bin/linuxarm64`. The pinned libei client is compiled into the executable to avoid a crash in SteamOS libei 1.4.1 when negotiation is aborted. Its MIT license and provenance are included. The remaining third-party libraries come from the installed runtime.
Host and ARM64 build directories are separate. If switching sysroots or toolchains for an existing build directory, use `cmake --fresh --preset frame-arm64` before rebuilding. The build script validates the executable's AArch64 architecture.
## Tests
CTest includes `languages`, `keyboard-core`, `settings`, `placement-instance`, `japanese-input`, and `rendering`. `ei-handshake` checks peer EOF and explicit cleanup during negotiation on private socketpairs, including ARM64 builds; it never contacts a compositor or announces an input device. The rendering suite compares incremental and forced full images pixel-by-pixel across all bundled languages, controller overlaps, animation reversal, modifiers, composition, settings, and profile changes. Host builds also run installer validation/failure/retry tests. If libeis 1.6+ development files are available, `ei-recovery` tests pause/resume, disconnect, and text-socket selection using an isolated compositor and dummy Wayland listeners that cannot deliver input to the desktop. They use capture sinks and temporary configuration directories, not user applications. The placement suite uses local Unix sockets. A restricted sandbox must allow those sockets.
Anthy integration is optional in normal test runs. To require the real conversion test when the library and dictionary are installed:
```sh
FRAMEKEYBOARD_TEST_ANTHY_REQUIRED=1 ctest --test-dir build/host --output-on-failure
```
`FRAMEKEYBOARD_TEST_ANTHY_DICTIONARY` can point to a nonstandard `anthy.dic`; `LD_LIBRARY_PATH` can select a matching library. These overrides apply only to testing.
ARM64 tests must run on an ARM64 machine. For example, after cross-building:
```sh
scp build/frame-arm64/keyboard-tests build/frame-arm64/placement-instance-tests \
build/frame-arm64/japanese-tests "$FRAME_HOST:/tmp/"
ssh "$FRAME_HOST" '/tmp/keyboard-tests && /tmp/placement-instance-tests && FRAMEKEYBOARD_TEST_ANTHY_REQUIRED=1 /tmp/japanese-tests'
```
Optional probes are excluded from CTest. `uinput-smoke-test` exclusively grabs its own device before emitting events. `ei-target-probe` and `japanese-target-probe` require focus in their own disposable Xwayland receiver. `vr-panel-probe` requires a specific input-disabled instance before posting UI events. `stick-component-probe` checks model calculations; `haptics-probe` physically vibrates controllers. Read each probe's source and usage before running it. Do not run input probes against the user's active typing session.
### Measure rendering
`render-benchmark` uses the production renderer with bundled English/Graphite profiles, a temporary config directory, and a `NullSink`. It cannot type into applications. It runs for about 35 seconds after warmup, measuring idle, one and four key presses per second, and forced redraws at a target 60 Hz. Each synthetic press lasts 100 ms; the normal 80 ms press/release animations determine redraw counts. It does not change the installed app or its settings.
After configuring the ARM64 build, run on the device:
```sh
cmake --build build/frame-arm64 --target render-benchmark
scp build/frame-arm64/render-benchmark "$FRAME_HOST:/tmp/"
ssh "$FRAME_HOST" 'XDG_RUNTIME_DIR=/run/user/$(id -u) /tmp/render-benchmark --vr'
```
Keep the headset awake and record the build type and runtime conditions. `--vr` requires SteamVR to be running already and creates its own hidden, noninteractive overlay. Omitting it measures CPU painting without connecting to SteamVR. `--full-paint` disables incremental painting, and `--rgba` forces the compatibility upload path. Combine both flags to compare with the original full-render/conversion approach. The final stress workload always forces full paints. Use an optimized build for performance comparisons; the host preset is Debug.
Output separates wall-clock painting and texture submission, with means, medians, p95 values, and process CPU time. Native upload has no pixel conversion; the compatibility path reports conversion and upload together. Upload includes staging copy, GPU fence wait, and OpenVR submission; it is not a GPU-only timer. Total redraw time includes minor measurement overhead. Before timing, the probe checks the overlay alpha flag and reads back its own texture where supported. Frame returns raw BGRA for native textures; matching those bytes verifies transfer but does not replace a headset check of colors and transparent edges. CPU percentages use one logical CPU as 100%, not the entire device.
This isolates rendering: it excludes the production controller/pose/haptic/input loop and visible compositor drawing. Idle reports the benchmark's idle cost, not the complete installed app's cost. Deadline overruns mean the benchmark loop exceeded 16.667 ms, not that SteamVR dropped a displayed frame. These measurements alone cannot establish battery life or power consumption.
### Measure replacement idle overhead
`scripts/measure-replacement.py` reads Linux process counters for the current installed keyboard, its helper and its native guard. It also reads available battery status and raw sysfs power counters. It does not send input, contact CDP, change settings or restart anything. For a Frame measurement, copy only this probe from the development host:
```sh
scp scripts/measure-replacement.py "$FRAME_HOST:/tmp/framekeyboard-measure-replacement.py"
ssh "$FRAME_HOST" 'python -B /tmp/framekeyboard-measure-replacement.py --seconds 20 --include-steam --label baseline' > baseline.json
```
Repeat with the same headset/dashboard state for a candidate build. A changed process identity invalidates the comparison. CPU percentages use one logical core as 100%. Context switches and read/write syscall counts do not identify CDP polls, and aggregate Steam webhelper CPU does not isolate `inspect()`. Short samples or charging cannot establish battery savings. Summon latency, native fallback and owner transitions need separate acceptance.
## Refresh the README image
Render the bundled default profile using an empty temporary config directory:
```sh
screenshot_config=$(mktemp -d)
mkdir -p docs/images
./build/host/framekeyboard --config-dir "$screenshot_config" \
--render docs/images/keyboard-graphite.png
rmdir "$screenshot_config"
```
Image exports omit the interactive preview's input notice. This is a native-renderer capture, not an in-headset photograph. It contains no browser window or personal text. Inspect it before committing. Keep the README caption explicit about its source.
The `languages` suite checks all added XKB profiles and, when compiled in, real Pinyin/Hangul engines plus app commit ordering. Set `FRAMEKEYBOARD_TEST_CJK_REQUIRED=1` to make missing engines a test failure. Run this on Frame for release verification. `japanese-target-probe --multilingual` checks a fixed mixed-script phrase in its own focus-checked receiver.
`unicode-target-probe` exercises the production App with German, French, Russian, Ukrainian, Brazilian, Korean and both Chinese profiles, without a target-language declaration. It creates temporary settings, re-focuses only its own disposable Xwayland window before each press, verifies focus before delivery, and checks exact output. It restores previous focus and deletes its temporary settings. Never run it against a user field.
+101
View File
@@ -0,0 +1,101 @@
# Configuration and VR switching
File-based layouts, language support and editable themes are core requirements. Users must be able to add profiles, then select them inside VR without recompiling or restarting the keyboard.
The app implements runtime profiles, VR selectors, reload and persistent selection. The bundled resources are also embedded for fallback. Bundled XKB layouts include English, German, French, Spanish, Italian, Brazilian Portuguese, Russian and Ukrainian. The normal libei/Gamescope path resolves characters locally and sends Unicode, independently of system layouts. Physical uinput and external JIS still require a matching `--target-language` declaration; application coverage varies. Integrated Japanese, Chinese and Korean composition is available; see [languages and system settings](languages.md).
## Independent profiles
| Profile | Owns | Example |
| --- | --- | --- |
| Layout | Key positions, sizes, physical key IDs and shortcut actions | `layouts/en-us.json`, `layouts/international.json` |
| Language | Input keymap, key legends, locale and font preferences | `languages/en-us.json`, `languages/de-de.json` |
| Theme | Colors, gradients, rounding, sides, press movement and animation timing | `themes/graphite.json` |
| Settings | Default profile IDs, favorite layout/language pairs and pin state | `config/default.json` |
Keep these separate. The same geometry can display English or German legends, and a theme can apply to either language. A favorite saves a layout/language pair for cycling from the main keyboard. Cycling preserves the active theme. Favorite records contain `id`, `name`, `layout` and `language`. The legacy `theme` field is optional, retained when saving, and does not affect cycling. Full-size is the default, but the runtime must not assume a fixed key count or hardcode their arrangement.
The current `en-us-full` layout ID identifies the approved ANSI geometry and its fallback legends. Its name does not restrict it to English. Languages declare `required_keys` as physical action names. The German profile requires `IntlBackslash`, provided by `international-full`. Activation rejects a layout missing that key. This international variant retains the rectangular Enter from the approved design.
## File locations and discovery
Packaged defaults live in the versioned installation's `share/framekeyboard/layouts/`, `share/framekeyboard/languages/`, and `share/framekeyboard/themes/` directories. User files live under `$XDG_CONFIG_HOME/framekeyboard`, defaulting to `~/.config/framekeyboard`:
```text
config.json
layouts/*.json
languages/*.json
themes/*.json
```
The installer must preserve user files during updates and must not rewrite them with bundled defaults. The OS image is not a configuration store. Each profile has a stable `id`, a display `name`, and a `schema_version`. Settings reference IDs, not absolute paths. A user profile with the same ID can override a bundled profile; the VR picker shows its display name. Reject duplicate IDs within a source directory instead of depending on filesystem order.
Keep a compiled safe default available. If a user override is invalid, retain the last validated profile and show its filename and error in settings. On a cold start, fall back to the bundled profile or compiled default. Unknown schema versions must not be guessed or silently rewritten.
## Layouts and styles
Layout JSON defines key rectangles and actions. An optional `icon` field accepts `copy`, `paste`, `steam-frame`, or `steam-os`; omitting it renders the text label. Icons use the theme legend color and move with the key face. Modifiers latch on tap by default. Set a key's `sticky` field to `false` for a native press/release hold; bundled right Super uses this for the desktop menu. The existing validator checks bounds and overlap. The runtime validator limits profile size, key count, label length and action types. Profiles are data and cannot execute commands or scripts.
Theme JSON defines renderer properties directly. Support surface and legend colors, key-face gradients, side colors/depth, corner radii, spacing/padding and font sizing, plus hover, pressed, latched and disabled state styles. The current renderer consumes the graphite fields plus `font_size`, `small_font_size`, `padding`, `hover` and `latched`. It uses immediate hover/latched colors and animated press travel. The `disabled` color blends with the legend color to dim unavailable settings controls; keyboard keys have no separate disabled state. Per-key fonts, arbitrary spacing rules and additional state styles remain future work. Native rendering does not require CSS or JavaScript theme execution.
Validate dimensions and animation limits. In particular, press travel cannot exceed side depth, and painting must stay aligned with hit regions. A theme may change appearance but cannot change the meaning of a key or the input keymap. Geometry changes belong to layout profiles.
## Languages and actual input
A language profile selects a keymap and derives character legends from it, including Shift and AltGr levels. Optional legend overrides are keyed by physical key ID and never change output by themselves. Keep physical positions such as `KeyY` separate from the symbol that a German keymap produces there. Special actions such as Enter and Copy remain explicit actions.
Language profiles use XKB identifiers. `input_method` defaults to `xkb`; `japanese-romaji` and `japanese-kana` select local composition. `chinese-pinyin-simplified`, `chinese-pinyin-traditional`, and `korean-2set` select local Chinese/Korean composition. Other input methods and nonempty XKB options are rejected. Korean `composition_keys` and `composition_shift` objects supply configurable jamo legends by physical action name; two-set conversion belongs to libhangul. They do not redefine the engine mapping. Direct Kana profiles supply `kana` and optional `kana_shift` objects mapping physical action names to UTF-8 text. `legends.overrides` maps a key ID to a display string. In normal Frame mode, XKB resolves characters inside the app. A persistent XKB compose state handles dead-key sequences and never consults legend overrides for output. Native shortcuts use the compositor's advertised keymap and active group where available. Only uinput and external JIS retain the frozen launch declaration check, including rules, model, layout, variant and options. Character input never changes global keyboard settings or falls back silently to raw keys when Unicode delivery fails.
Prove US English and German as the first two profiles. German exercises swapped Y/Z, umlauts and AltGr. Additional languages must be addable through files where the backend already supports their input method. Local dead-key composition and Unicode delivery are tested, along with the bundled CJK engines. Arbitrary Compose-key sequences, right-to-left scripts and other IMEs still need explicit capabilities and tests; do not claim support from locale names alone. If a profile needs an unavailable capability, show the reason and leave the working profile active.
Use font fallback and text shaping for non-Latin legends. Treat interface translation separately from typing language; changing the typing language must not unexpectedly change the settings UI language. Do not use the clipboard as an implicit text-insertion fallback.
## Switching inside VR
The main toolbar has Settings, Recenter, smaller/larger zoom buttons, then Pin and, when favorites exist, a language abbreviation. The pin changes its icon when enabled without an active background. Pin prevents both movement methods and hides the line. Its state is saved immediately in the optional boolean `pinned` field of `config.json`. It does not stop dashboard following, resizing or recentering. Missing `pinned` defaults to false. A new install has no favorites.
Settings has Back, Apply and save, and Reload config icons at the top left. The Languages and Layouts card contains Layout and Language dropdowns and a Favorite checkbox. The Experimental card contains left-aligned Replace native keyboard and Start with SteamOS labels followed by their checkboxes. Start with SteamOS is disabled while replacement is unchecked. The General card contains a Theme dropdown and vertically aligned Hide numpad, Use Drag to Move and Auto leveling checkboxes, with labels on the right. Dropdowns have no search and show up to five choices. Scroll to reach the rest, click a choice to select it, or click outside the list to dismiss it. Dismissal consumes that click. Escape in the desktop preview dismisses a list first, then leaves Settings.
The card row scrolls horizontally when more cards are added. Each card has its own vertical scroll offset when its contents overflow. Cards and fields outside their viewports are hidden from painting and hit testing. Dropdowns are drawn and hit-tested above those clips and can extend beyond the keyboard case. Use scroll input or drag a scrollbar with the trigger. In the desktop preview, Shift plus the mouse wheel scrolls horizontally. A vertical wheel over a card with no vertical overflow scrolls the card row. The production UI has three cards, Languages and Layouts, General and Experimental; automated tests supply extra cards and fields to exercise both axes.
Num Lock is saved immediately as the optional global boolean `num_lock`, defaulting to false. It survives closing, reopening, layout changes and favorite cycling. Restoring it sets local legends and Unicode mapping without sending startup input. In physical uinput and external JIS modes, keypad input first reconciles the target lock through reported LED or XKB modifier state. If synchronization is needed, the panel asks you to press the keypad key again after feedback arrives. Targets without lock feedback cannot receive lock-dependent keypad input safely; ordinary keys remain available.
The General card's Hide numpad checkbox removes Num Lock and all Numpad keys, narrows the case, and keeps the remaining keys at the same size. It is saved as the optional global boolean `hide_numpad`, defaulting to false, and applies to every layout and favorite. Showing the numpad again restores its remembered Num Lock state. The transparent space beside the narrower case is excluded from VR interaction. Settings retains its full card area.
Checkbox edits stay pending across dropdown selections. Apply and save activates the chosen profiles and saves all pending favorites, numpad visibility and movement behavior; Back discards pending edits. The checkbox reflects the current layout/language pair, irrespective of theme. Unchecking removes duplicate legacy records of that pair. At most 32 favorite records can be saved.
The main language button cycles through the default saved by Apply and save, then unique valid favorites in saved order. A default that is already a favorite appears once. If the only favorite matches the default, the button has no other state to cycle to. Invalid or unavailable favorites are skipped. Cycling changes the current keyboard without replacing the saved default. Saving pin state while on a favorite also preserves that default. Reload rereads profiles, favorites, pin state, Num Lock and numpad visibility, closes dropdowns, cancels UI captures, and resets pending edits while retaining the current active keyboard. Malformed config edits report an error and retain the last loaded global settings and favorites. Newly added profile files appear after reload; an automatic file watcher is optional.
Japanese Romaji, Kana and external JIS modes are choices in the Language dropdown. They use the same compatibility validation and Apply path as other languages.
Validate and prepare a candidate configuration off to the side. On Apply, cancel active pointer presses, stop repeat and release held keys through the old backend before changing mappings. Clear latched modifiers, replace geometry and hit regions together, and redraw. Preserve the target application's focus. A failed apply keeps the previous working configuration and reports the error in VR.
Persist successful selection to `config.json` using an atomic write. If saving fails, keep the usable runtime configuration and report that it will not survive restart. Reloading files must not overwrite unrelated favorites or silently activate an edited profile while a key is held. The active selection survives app restart; the installer preserves user files across application updates. Reload releases held keys and reloads favorites. After Apply or Reload, typing requires `--start-enabled`, a live backend, and an available Unicode connection. Only physical uinput/external JIS require the selected language to match the declared target keymap. In those physical modes, a saved mismatch also permits startup with Settings accessible. A mismatch leaves the panel visible but input-disabled until a matching profile is selected or the app is reopened with matching target options. It keeps the active selection rather than activating a config.json edit behind the user's back.
## Implementation order
Profile parsing, catalog discovery, native rendering and VR selector code are implemented. Next verify controller interaction and real language output in both target sessions. See the plan and acceptance checks for the remaining integration work.
The General card has **Use Drag to Move**, saved as the optional boolean `use_drag_to_move`. It defaults to false, including for older configurations. When off, hold the trigger on the white line below the keyboard to move it. When on, point at the keyboard and hold grab/grip to move it. The line is hidden in grab mode. Pinning disables both movement methods and hides the line. Apply and save activates this preference; Back discards pending changes.
## Saved VR placement
`placement.json` is separate from profile selection and honors `--config-dir`. Schema 1 stores `space: "standing"`, a decimal-string `universe` ID, a three-by-four rigid `transform`, and `width_m` between 0.45 and 2.0. An optional `dashboard_anchor` stores the last rigid bottom-center dashboard pose. On reopen the saved relative offset is applied to the current dashboard. The `dashboard_full_rotation` flag distinguishes full-orientation bar poses from older yaw-only saves, which migrate without an initial tilt jump. An optional `dashboard_bar` stores the matching bar pose so the bottom-center anchor can follow app tabs while the main tab is hidden. Legacy files without this field remain valid and use their world-space pose until reset. The app writes it atomically on orderly close, including SIGTERM. It stores position, orientation and size, with no key input data. A missing or invalid file recenters on first launch. A relaunch of an already running instance always requests recentering, without reloading this file. The next close saves the new position. Forced termination cannot save the latest movement.
The General card also has **Auto leveling**, saved as the optional boolean `auto_leveling`. It defaults to true, including for older configurations. When enabled, a sideways roll within 5° eases level over 500 ms after releasing movement, preserving pitch. When disabled, the chosen roll remains unchanged. Applying or reloading a change preserves the displayed pose; enabling starts a fresh easing interval. Back discards pending changes.
## Optional native keyboard replacement
Experimental → Replace native keyboard is saved as the optional boolean `replace_native_keyboard`, defaulting to false. Apply and save activates the preference; Back discards pending edits. The installed VR launch must have typing enabled, a working libei/Unicode backend and access to Steam's existing local debugger at `127.0.0.1:8080`. This dependency applies only to replacement. Enabling the option leaves Developer Mode, ports, system services and autostart unchanged. Missing requirements leave replacement inactive and Steam's keyboard usable. Settings exposes **Use Steam keyboard** during an active native request while this option is on. That action restores native for the current request; future requests may use Full Keyboard again. See [connection requirements, coverage and failure behavior](native-replacement.md).
With replacement enabled in VR, the main × dismisses the typing session and hides the panel while the app stays resident. Native summon requests can then reopen Full Keyboard without another manual launch. **Quit Full Keyboard** is shown only in Settings while replacement is enabled; that action exits the app and restores native fallback. With replacement disabled, × retains its normal quit behavior.
## Manual updates
The Settings header has a centered Check for updates button. Its latest-version message resets after five seconds; an available update remains offered until the process exits. This state is not a saved preference. Download and install update verifies, stages and restarts into a newer stable GitHub release. It preserves configuration and placement. See [updates](updates.md).
Experimental → Start with SteamOS is saved as the optional boolean `start_with_steamos`, defaulting to false. Its choice is remembered while replacement is off, but startup is active only when both saved booleans are true. Applying either option updates the user-local startup unit on the preference worker; the VR loop keeps running. See [background startup](native-replacement.md#start-with-steamos).
Saving VR preferences couples the replacement and startup booleans to the startup-unit transaction. If unit configuration fails, other preferences still save, those two saved booleans keep their prior values, and a separate startup warning explains which changes were not saved. If the JSON write itself fails, the previous unit and enablement link are restored and the user manager is refreshed. A later successful save clears the warning or error.
Reload also reconciles the startup unit with valid saved settings on the same worker. It does not rewrite JSON or activate an edited profile. A failed reconciliation reports a startup configuration error; Reload retries it. Preview and render modes do not configure startup.
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Full Keyboard input check</title>
<style>
body { max-width: 760px; margin: 32px auto; padding: 0 20px; background: #171b20; color: #edf0f4; font: 18px system-ui; }
input, textarea, button { box-sizing: border-box; width: 100%; padding: 12px; margin: 8px 0; border: 1px solid #6c7684; border-radius: 8px; background: #282e36; color: inherit; font: inherit; }
textarea { min-height: 100px; }
:focus { outline: 3px solid #81bbf7; }
li { margin: 10px 0; }
output, pre { display: block; padding: 12px; background: #222830; white-space: pre-wrap; }
</style>
<h1>Full Keyboard input check</h1>
<p>Use only the sample text below. This page has no network requests, saved history or clipboard access. The event display shows native controls, not typed characters.</p>
<ol>
<li>Focus the first field with the controller, then type <strong>frame test 123</strong> on Full Keyboard. Check that characters arrive once and focus stays here.</li>
<li>Press main Enter, then numpad Enter. Each should increase the submit count once.</li>
<li>Use Tab and Shift+Tab to move between fields. Try arrows, Home, End, Backspace, Delete, and numpad navigation with Num Lock off; then numpad digits with Num Lock on.</li>
<li>In the first field, use Ctrl+A, Copy, Tab, then Paste. The second field should contain the same sample. This replaces your clipboard with the sample when you press Copy.</li>
<li>In the multiline field, Enter should insert a newline. Hold Ctrl with one controller and press an arrow with the other. Tap Alt then A to check the modifier display; this page consumes Alt+A.</li>
<li>Switch to another local app while a modifier is latched, then return. Close the dashboard and reopen it. Check that no key or modifier remains stuck.</li>
</ol>
<form id="form">
<label for="first">First field</label><input id="first" autocomplete="off">
<label for="second">Second field</label><input id="second" autocomplete="off">
<button type="submit">Submit</button>
</form>
<output id="count">Submissions: 0</output>
<label for="multiline">Multiline field</label><textarea id="multiline" autocomplete="off"></textarea>
<h2>Native controls</h2><pre id="events">Waiting for a control key.</pre>
<script>
let submissions = 0;
const events = [];
document.querySelector('#form').addEventListener('submit', event => {
event.preventDefault();
document.querySelector('#count').textContent = `Submissions: ${++submissions}`;
});
for (const type of ['keydown', 'keyup']) {
document.addEventListener(type, event => {
if (event.altKey && event.code === 'KeyA') event.preventDefault();
const control = /^(Enter|NumpadEnter|Tab|Backspace|Delete|Home|End|Arrow|Control|Alt|Shift|Meta|NumLock)/.test(event.code);
if (!control && !(event.ctrlKey || event.altKey || event.metaKey)) return;
const modifiers = [event.ctrlKey && 'Ctrl', event.altKey && 'Alt', event.shiftKey && 'Shift', event.metaKey && 'Super'].filter(Boolean).join('+');
events.push(`${type}: ${control ? event.code : 'shortcut'} ${modifiers}`);
document.querySelector('#events').textContent = events.slice(-8).join('\n');
});
}
</script>
</html>
+43
View File
@@ -0,0 +1,43 @@
# Japanese input
Open the Language dropdown in Settings, choose Japanese Romaji, Kana, or JIS, then use Apply and save. Selection keeps the theme and chooses compatible keyboard geometry when the current layout lacks required keys. Choose the JIS layout explicitly if you want its dedicated Japanese keys. Existing English/German profiles remain available in the language selector.
| Preset | Entry | Conversion/output |
| --- | --- | --- |
| 日本語 Romaji | Latin letters, such as `nihon` | Local kana composition and Anthy kanji candidates |
| 日本語 Kana | Direct JIS-position kana keys | Same local conversion and candidate controls |
| JIS (system IME) | Physical JIS keys, including 変換/無変換/かな | Conversion handled by an already configured target IME |
## Built-in composition
Romaji is the default Japanese choice for someone unfamiliar with Japanese keyboard layouts. It supports common Hepburn and Japanese IME spellings, doubled consonants, `n'` disambiguation, `nn`, and small kana with `x`/`l` prefixes. Additional aliases include `ca/ci/cu/ce/co`, `fya/fyu/fyo`, `tha/thi/thu/the/tho`, `dha/dhi/dhu/dhe/dho`, and `wu`, following the corresponding entries in [Mozc's romaji table](https://github.com/google/mozc/blob/master/src/data/preedit/romanji-hiragana.tsv). Direct Kana has Shift variants for small kana and punctuation; ゛ and ゜ combine with the preceding kana.
- Space or 変換 starts conversion, then advances candidates. Shift+Space or ↑ goes back. Click a candidate to select it.
- ←/→ select the phrase segment. The active segment is marked with 【 】 in the preedit. The selected candidate stays highlighted; ↑/↓ move through pages as needed.
- Enter or 確定 / Commit sends the completed text. It does **not** submit the target form. Enter with no composition sends a normal physical Enter.
- ひらがな/F6 and カタカナ/F7 change the reading's script. 無変換 restores the hiragana reading.
- Backspace removes a kana/pending roman letter; during candidate selection it first returns to the reading. Escape first cancels conversion, then clears the reading. Cancel clears it immediately.
- あ / A toggles local composition and Latin keys. The JIS 半/全 and かな keys also toggle composition in the integrated modes.
- Starting another word while candidates are active commits the chosen phrase first. Tab, Delete, Home/End, Copy/Paste, and Ctrl/Alt/Meta shortcuts first commit unfinished composition, then send their physical key events. If committing fails, the text remains and the key or shortcut is not sent. Escape and the Cancel control still cancel deliberately.
The preedit stays in the keyboard until committed. Moving the laser off the keyboard keeps it. Opening Settings, dragging, hiding, recentering, changing profiles or losing input focus discards it. This avoids committing an old phrase after a focus transition. Input routing follows compositor focus, just like the ordinary keyboard.
## Runtime requirements
The integrated modes use `libanthy.so.0`, its installed dictionary and Japanese fonts. These were already present on the tested Steam Frame. No daemon, browser, online converter, clipboard fallback or dictionary training is added. Conversion runs only when requested; ordinary polling/rendering stays adaptive. The library/dictionary stay loaded after first conversion until the keyboard exits.
The normal launcher with `--input ei --start-enabled` supports the bundled integrated profiles. Japanese characters are composed locally. The normal launcher does not require a matching system keymap; native shortcuts use the compositor keymap where available. The text backend checks the private [Gamescope input-method protocol](https://github.com/ValveSoftware/gamescope/blob/6867f509874f9bc52e12d6f4c4596cdf0d5be6b4/protocol/gamescope-input-method.xml) before enabling output. Missing support leaves a conversion preview and a visible error. The uinput backend cannot deliver composed Japanese text. A numbered `--ei-socket` selects the matching text compositor; custom names need `--text-socket`. See [runtime routing](runtime.md).
External JIS is for a session already using the Japanese physical keymap and a working system IME. Close the running keyboard before changing launch options:
```sh
~/.local/bin/framekeyboard --vr --input ei --target-language ja-jis --start-enabled
```
Then select JIS (system IME) in Settings. Selecting JIS while still declaring a US target disables output; it never changes the system layout/IME silently. The geometry uses a tall rectangular Enter key.
## Current limits and validation
Reading is limited to roughly 30 Unicode characters per phrase, with at most 32 in one native commit. Candidate lists expose up to 128 alternatives per segment. Full paragraph editing, segment-boundary resizing, prediction, custom learned dictionaries, half-width kana conversion and repeat while holding composition keys are not implemented. The desktop preview composes locally and never sends OS input.
Host and ARM64 tests cover romaji, voiced/small kana, candidate selection, scripts, failed commit retention, cancellation, opt-in and modifier routing. The dedicated on-device Xwayland receiver verified 日本 via the native UTF-8 connection. That does not establish every nested desktop/browser or external system-IME combination; test a disposable field in the desired application.
+110
View File
@@ -0,0 +1,110 @@
# Languages and system keyboard settings
The typing language is an app setting, separate from SteamOS's interface locale.
A fresh installation starts in English US. After Apply and save, the keyboard
reopens with the last saved language, layout and theme. A Russian system locale
does not automatically select Russian. Updating the app preserves your selection.
## Included profiles
| Language | Profile ID | Layout | Character mapping |
| --- | --- | --- | --- |
| English US | `en-us` | US full-size | US XKB |
| German | `de-de` | International full-size | German XKB |
| French | `fr-fr` | International full-size | French AZERTY, France |
| Spanish | `es-es` | International full-size | Spanish, Spain |
| Italian | `it-it` | International full-size | Italian XKB |
| Portuguese, Brazil | `pt-br` | Brazilian ABNT2 full-size | Brazilian XKB, including extra slash and keypad separator |
| Russian | `ru-ru` | US full-size | Russian ЙЦУКЕН |
| Ukrainian | `uk-ua` | US full-size | Ukrainian ЙЦУКЕН |
| Simplified Chinese | `zh-cn-pinyin` | US full-size | Local Pinyin with candidates |
| Traditional Chinese | `zh-tw-pinyin` | US full-size | Local Pinyin with Traditional output |
| Korean | `ko-kr` | US full-size | Local two-set Hangul |
| Japanese | `ja-romaji`, `ja-kana`, `ja-jis` | US or Japanese JIS | See [Japanese input](japanese.md) |
Open Settings, select Language, then Apply and save. When the current geometry
lacks required keys, the language selector chooses the smallest compatible
layout. Layout remains independently editable. Theme selection is unchanged.
French and Spanish regional variants beyond France and Spain, Chinese Zhuyin,
Cangjie and Wubi, and Korean Hanja conversion are not included.
## Independent layout selection
The normal Frame launcher sends characters as Unicode from the profile selected
in Full Keyboard. Switch languages in Settings and Apply and save. You do not
need to install matching system layouts, change SteamOS's active layout, pass a
launch flag, or restart the app. Shift, Caps Lock, AltGr, dead-key accents and
numpad digits are resolved locally. Legend overrides remain display-only; the
profile's XKB mapping determines the characters.
A Russian system locale still does not choose the initial app language. A fresh
install starts in English; subsequent launches restore your saved selection.
With several system layouts installed, Full Keyboard continues using its own
selected language. System layout-switch hotkeys do not change our selection.
Enter, arrows, Backspace, Tab and shortcuts remain native key events. For Latin
shortcuts, the app reads the compositor's advertised keymap and group to find the
right physical key. For example, the A shown on an AZERTY layout sends Ctrl+A,
not Ctrl+Q. Copy/Paste use the same lookup. A non-Latin target group uses its
Latin physical counterpart when the compositor exposes one. Applications still
define shortcut behavior; a compositor that supplies no usable keymap falls back
to conventional physical Latin positions.
Unicode output needs the Gamescope text connection. If it is unavailable, typing
is disabled with an error instead of falling back to a potentially wrong physical
layout. A failed single-character commit remains queued; release other held keys
and press Enter to retry without submitting a form. Hiding or cancellation clears
queued text. Character repeat starts after 500 ms, repeats at 25 Hz, and stops on
release/cancellation without replaying a backlog.
The developer `--input uinput` backend and Japanese external JIS/system-IME mode
remain physical-key modes. They still require the receiving keymap to match
`--target-language`. Integrated Japanese, Chinese, Korean and the ordinary XKB
profiles use independent Unicode output through the normal Frame launcher.
## Chinese and Korean composition
These profiles compose inside Full Keyboard, then submit Unicode text through
the same Gamescope text connection as the other layouts. English and the local
Japanese, Chinese and Korean modes can be selected in VR without changing the
system keymap or relaunching. The receiving app does not need its own IME enabled.
Shortcut and navigation actions remain native. This still requires a local app
that accepts Gamescope's text delivery.
For Chinese, type Pinyin without tone numbers. Use `v` for ü and an apostrophe
for syllable separation where needed. The candidate row shows five entries at a
time. Arrow Up/Down or the candidate-row arrows change the highlighted entry.
Space, number keys 1–5, or clicking a candidate selects it. If it covers the
whole reading, selection commits the text. Otherwise the remaining reading stays
available for the next selection. Enter or Commit submits the current conversion;
Escape or Cancel discards it. A second Enter sends an ordinary Enter. Both script
profiles use Pinyin; Traditional here does not mean a Zhuyin layout.
For Korean, type the displayed two-set jamo. Shift selects doubled consonants and
the shifted vowels. Caps Lock does not double jamo. Backspace undoes one typed
jamo, including changes across syllable boundaries. Enter or Commit submits the
composition. Space and other ordinary keys commit the word before sending the
key. Use the `한 / A` control to switch between Hangul and Latin typing. Chinese
has a corresponding `中文 / A` control. Punctuation uses ordinary US keys.
Both modes keep a bounded preedit. Commit before the 30-input-character limit.
Shortcuts and navigation that leave composition commit first; if delivery fails,
the preedit stays visible and the forwarding action is blocked. Hiding, dragging,
recentring, changing profiles, and closing discard unfinished preedit, as with
Japanese. Automatic character repeat is not implemented for local composition.
Chinese uses the Frame's PyZy library and dictionary; Korean uses libhangul.
Both are loaded only when their language is selected. Missing libraries disable
that composition profile, not the whole keyboard.
No IBus daemon configuration, system language setting, clipboard access, network
service, or persistent typing history is used. PyZy's conversion/selection
learning APIs are deliberately avoided, and its user database has no writable
path. A build without an engine rejects its profile on Apply while retaining the
previous profile. A saved unsupported composition profile falls back to English
at launch with an error message.
Automated tests cover profile legends, Chinese script conversion/candidates,
Korean syllables/editing, and commit ordering. A dedicated receiver checks Unicode
transport separately from composition. Fluent-speaker review and per-application
acceptance are still needed; passing a local conversion test does not establish
that every browser or desktop session accepts it.
+149
View File
@@ -0,0 +1,149 @@
# Experimental native keyboard replacement
In Settings → Experimental, enable **Replace native keyboard**, then Apply and save. It is off by default. Full Keyboard must already be running with typing enabled. With replacement enabled, X hides the panel and leaves Full Keyboard resident for the next native request. Replacement alone changes no startup settings or Steam files. The separate **Start with SteamOS** checkbox is off by default and can only be edited while replacement is checked.
The current replacement bridge requires Python 3 and Steam's existing debugger at `127.0.0.1:8080`. It uses that local connection to install temporary keyboard hooks in Steam's UI. Ordinary Full Keyboard typing works without the debugger. If the debugger or required interfaces are unavailable, replacement stays inactive and native requests keep using Steam's keyboard. The saved preference is retained for a later available connection.
SteamOS can separately provide port 8081 through `steam-web-debug-portforward.service` for remote debugging from another computer. Full Keyboard does not connect to port 8081. It opens no debugging listener and leaves SteamOS's ports, forwarding services and Developer Mode settings unchanged. Installation adds no administrator or port-configuration step.
The bridge checks that its debugger endpoint on port 8080 is bound only to loopback. Port 8081 is unused and does not affect replacement availability. A Steam UI update may disable replacement until compatibility is restored.
When replacement cannot run, Settings reports a fixed reason: unavailable debugger, blocked local debugger access, incompatible Steam keyboard interfaces, another active replacement, unavailable helper, or lost connection. It also confirms that Steam's keyboard remains available. No raw exception, field value or input packet is displayed. A successful reconnection clears the notice.
Replacement writes `[native-replacement]` diagnostics to the app's stdout. Launcher
starts appear in Steam's `steam_output.log`; boot starts appear in
`journalctl --user -u framekeyboard-autostart.service`. Lines contain only fixed
helper/session codes, request numbers and retry delays. Install and repeated
reply timeouts have distinct codes. Field values, typed keys, input packets and
helper stderr are never forwarded. The bridge retains the latest 32 sequenced
transitions so multiple changes between polls can be reported once. Output is
limited to eight lines per second. An unchanged failure and retry delay is
reported at most once per minute.
## When it runs
Checking the option and choosing Apply and save starts the bundled helper and a temporary watchdog inside the running Steam UI. It does not install a background service. Unchecking it and applying stops the helper, removes the Steam hooks/watchdog, and restores native presentation. The app's internal safety thread stays idle while replacement is off and ends when the app exits.
With replacement enabled, the top-right X dismisses the current typing session and hides the panel. Full Keyboard and its bridge keep running, ready to show our panel on the next supported native request. Dismissing X does not bring back the stock keyboard. Dismissals are scoped to their request, and duplicate native close notifications cannot create new sessions. Pressing a supported native keyboard button while our acknowledged panel is open dismisses both presentations, including after closing an app and returning to the dashboard. Explicit Steam field-focus requests transfer the typing target and keep our panel open. The bridge distinguishes these requests through the existing field-manager show path, rather than treating a stale field reference as new focus.
**Quit Full Keyboard** appears in Settings only while replacement is enabled in VR. Use it to exit the resident app and restore native fallback. With replacement disabled, X exits normally and the separate Quit action is absent. Desktop preview keeps its ordinary exit behavior.
The preference is saved, so launching Full Keyboard again recreates the bridge when replacement is enabled. After an explicit Quit or uninstall, native requests use Steam's keyboard until Full Keyboard is launched again. Without **Start with SteamOS**, a crash or reboot also requires a manual launch. The native button summons a running resident; it does not launch a stopped app. Relaunching the existing resident app reopens/recenters its panel so Settings remains reachable.
Repeated debugger, compatibility or helper failures before a usable state back off from 5 seconds to a 60-second retry interval. A successful state, re-enabling replacement, or an explicit relaunch/recenter request resets the delay. A busy replacement or lost established connection retains the 5-second retry so temporary ownership and transport interruptions can recover promptly.
## Start with SteamOS
Check **Replace native keyboard**, then **Start with SteamOS**, and choose Apply and save. The startup checkbox is disabled while replacement is unchecked. Both saved preferences must be on for startup to run. Turning replacement off removes boot startup; turning it on again restores startup if its saved checkbox was selected. Unchecking startup alone removes it while replacement keeps working in the current session. Back discards pending edits.
This creates only a user-local `framekeyboard-autostart.service` and a link under `~/.config/systemd/user/steamvr.service.wants/`. It follows the Frame's existing SteamVR service and retries after crashes or runtime shutdown. It does not start SteamVR itself, require administrator access or alter Steam files. Startup begins with the panel hidden, including while the debugger reconnects; the app, replacement helper and watchdog run normally. The native keyboard button or supported field focus summons it. A duplicate boot start cannot summon an already running panel. Launching Full Keyboard manually still summons/recenters it and reaches Settings. Applying or reloading settings migrates the application's old gamescope-session startup link automatically.
Settings Quit pauses the app until the next SteamVR start or SteamOS login if both options remain enabled. Disable Start with SteamOS and apply to keep it off across those starts. Deliberate VR exits use status 3, which the unit treats as successful without an automatic restart; runtime shutdown uses normal exit status and is retried. The unit preserves independent fallback helpers and in-app update handoff when the main process exits. Startup reads the saved settings before opening input or VR, so a stale unit cannot activate it with either option off. If startup fails, Steam's keyboard remains available. Preview/image exports never install a startup unit.
Repeated startup failures use five restart-delay steps from 3 seconds up to 60 seconds. These backoff directives require systemd 254 or later; older managers ignore them and retain the 3-second delay. VR launches check for a running SteamVR server before parsing profiles or connecting input. Existing owned units receive the new settings through startup reconciliation.
## Coverage
Service-started keyboards retain service supervision through an in-app update or rollback. The original process stays registered until systemd confirms the replacement's PID. Subsequent crashes and SteamVR shutdown still use the startup unit's recovery policy. A manual app launch remains independent of the startup service.
Requests come from Steam's shared VR keyboard status, including Steam field focus and dashboard summons. Steam field takeover requires a matching window, a target-bound input route and a native Show/Hide provider that can restore that target. A direct field callback is preferred. Otherwise, the bridge requires an editable input or textarea in the manager's own document, verified by Steam's retained field reference and validity predicate. Unknown targets stay native. Steam's global controller-text API can send input to a local app behind the dashboard, so replacement never uses it. Frame-local app requests retain their supported replacement path.
Frame-local Gamescope desktop/window requests are also supported when their metadata matches the dashboard's minimal, single-line request with flags 13 and SteamVR dispatch disabled. These use the ordinary local libei/Unicode backend. An app-scoped native guard must first resolve the exact requesting overlay and arm recovery. It can reopen native for that overlay through OpenVR, independently of Python and the Steam UI. If that guard is unavailable, the request stays native. Unknown targets, mismatched Steam owners, other external request modes and buffered SteamVR requests remain native. A native keyboard already open when the bridge connects stays native until dismissed. Streamed VR-app input remains outside the project scope.
The bridge closes native presentation through its owning provider and temporarily preserves the Steam manager's logical open state and target. It suppresses only the corresponding physical-close cleanup. A real target change, explicit dismissal or another native request still receives the appropriate cleanup and request generation. Supported Steam fields receive committed Unicode and plain Backspace, Enter, Tab and arrow actions, preserving the field's Enter callback. Callback routes temporarily bind the renderer override to the retained callback and restore it after each call. DOM routes edit and move the caret only in the retained owner's document, even when that window lacks compositor focus. They never read existing field text. Every dispatch revalidates the target; blur ends the session without reviving the old field. In Steam-field sessions, physical modifiers, Copy/Paste, shortcuts and unsupported controls are blocked with a notice; use Steam keyboard for those actions. Shift/AltGr used to compose Unicode remain available. Local-app sessions keep ordinary libei shortcuts. External JIS and buffered external requests are excluded.
The dashboard keyboard button can also open our panel before a field is selected. Plain text then has no recipient and is discarded rather than saved for a later field. Selecting a supported Steam field creates a new request and routes subsequent typing to it. A new button summon can restore Steam's last field only when Steam still considers it valid; idle polling never restores a dismissed field.
PgUp and PgDn are unsupported in Steam replacement sessions, including a dashboard summon with no field selected. They show the control-unavailable notice in either case. Use the normal controller controls to scroll the dashboard. This limitation does not indicate that a blurred search field still owns input; clicking outside search must end its session and dismiss the panel.
Some Steam field predicates check the shared context's document instead of the owner's document. The bridge also records a field reference when Steam selects it synchronously during that owner's DOM focus or click event. This association remains valid only for the same connected, editable, active element and reference. A ref selected outside that event cannot acquire an association merely because an input is focused.
## Presentation and fallback
A prepared in-app update explicitly closes its retained request and cancels the local-app recovery guard before handing off. It discards queued input and does not reopen native. Ordinary Quit, disable, helper loss and timeouts keep native recovery. An unrelated native keyboard already opened by another owner is preserved.
While the app's render/input loop is healthy, the bridge prepares a transparent, noninteractive native popup texture and gates its show/focus calls. A supported request closes native presentation through the original provider. Our panel becomes eligible only after that close is confirmed, removing the native scene's collision and movement line before takeover. Each new request or field target still needs acknowledgement from our visible, input-ready panel within 400 ms. A failed native close or late acknowledgement leaves or restores the stock keyboard. Browser HideWindow is never used because it leaves separate dashboard geometry alive and can trigger focus-driven reopening. Stock controller keyboard actions are blocked while replacement owns the retained session.
Background startup prepares our hidden texture before takeover readiness. Changed helper session metadata wakes the hidden frame sleep, including while native close is still pending. Ordinary idle replies retain the original frame deadline. Waiting alone cannot renew readiness or acknowledge a panel; only a completed render/input frame can do that.
When Steam clears a field, its replacement session ends and the panel hides.
Selecting another dashboard frame also invalidates the old request, even if
its hidden document retains an active input. While the panel is open, it follows
the selected Steam frame or a previously acquired local-app frame. Local targets
are remembered only for this bridge lifetime, with a bounded frame-to-overlay
map. Returning to one creates a fresh request, cancels queued input and requires
the independent native guard to verify that exact overlay is active before local
input resumes. A first request in a local app still comes through its normal
keyboard button. Unknown or unsupported frames cannot inherit another app's
typing route. Dismissal, native fallback and lease expiry prevent automatic
reopening. The guard never restores a followed local app after it ceases to be
the active dashboard overlay.
Clearing a field does not prove which app has compositor focus, so the bridge
never changes that session into local input. Ending the session advances the
request generation and cancels queued text and holds. Focus another Steam field
or summon the keyboard again to start a new session with fresh acknowledgement.
Fallback also closes a field session whose target has disappeared;
it never reopens a native keyboard for that missing field, including when the
lease expires before the next field-change poll.
The stock dashboard handle is owned separately by SteamVR. Closing the actual presentation is required to remove it; CSS alone cannot do that. Native show/close ordering can still change. Confirm no flash, stale texture, collision or remaining handle in the headset before treating the prototype as complete visual replacement.
An independent watchdog in Steam's SharedJSContext expires the lease after 900 ms without new render-loop progress. Traffic from the helper alone cannot renew it. Readiness timeout, unsupported targets, backend loss, explicit escape, helper disconnect or app failure restore native presentation. An in-process watchdog hides our overlay after a 700 ms main-loop stall so it cannot cover native recovery. Its private health pipe reports watchdog progress independently of rendering; that health never renews the native takeover lease.
While replacement runs, the helper also monitors the main loop, including when the panel is hidden. Three seconds without a completed frame triggers SIGTERM to its own original parent, followed by SIGKILL after a one-second grace if the main loop still has not progressed. A live overlay watchdog alone cannot postpone this recovery. Profile preparation, language conversion and settings flushes explicitly declare slow work. With a live watchdog, they get an allowance of at most 30 seconds from the last completed frame. Repeated operations and watchdog heartbeats cannot extend that frame's allowance. Native fallback still proceeds during the allowance; only a completed frame can restore takeover readiness. For profile and storage work, if the watchdog also stalls, the ordinary three-second deadline applies. Termination releases the instance lock and compositor input devices, so a later manual launch can start a fresh app. It never terminates Steam or another application. These bounded allowances can still terminate unusually slow work that exceeds their limit; sleep/wake and recovery latency require device acceptance.
System resume and SteamVR runtime waits have separate bounded recovery allowances. The helper detects system suspend from the difference between Linux's BOOTTIME and MONOTONIC clocks and gives the main loop 30 seconds after resume to complete a frame. Repeated wakes without a completed frame cannot extend that grace. Main-thread OpenVR calls and texture submission declare runtime waits before entering the call. A wait can last at most 30 seconds from the last completed frame, including when the overlay watchdog is blocked on SteamVR too. Returning from a call leaves at most three seconds to complete the frame, within that same hard limit. Runtime calls cannot renew the native takeover lease. Ordinary main-loop deadlocks still use the three-second limit; a runtime wait exceeding its allowance is terminated rather than left resident forever. These allowances reduce false hang detection during wake/restart and do not promise recovery from an indefinitely stalled SteamVR service.
After tracking loss, the hidden resident continues checking headset pose every 250 ms while the dashboard is open. A valid pose restores readiness for a fresh request even if the previous lease expired. This check does not show the panel or reopen an expired, dismissed or explicitly returned native request.
A signal-stopped app is a separate case. While monitoring its original parent, the helper checks `/proc/<pid>/stat` for uppercase `T` before applying any readiness or recovery grace. A confirmed stop goes directly to SIGKILL because the frozen watchdog cannot hide the panel and SIGTERM cannot execute while stopped. Sleeping, blocked and tracing-stop states do not trigger this path. Unreadable process state retains the ordinary recovery deadlines. Tests freeze only a disposable fixture process, never the installed keyboard.
In Steam sessions, Esc dismisses the current panel like X. Open the gear menu and select **Use Steam keyboard** to give the current request back to Steam. Timeout, disable, exit and removal reopen the original owner's native provider while keeping its Steam field target intact, then remove presentation suppression. The restored request remains native until dismissed. The helper exits on parent EOF and removes its hooks when CDP is available. If CDP is lost, the independent Steam-side lease performs restoration. A subsequent bridge can replace an expired hook instance. No surviving helper is meant to suppress native input after uninstall.
The helper never reads initial field text or clipboard contents. Text travels transiently through the private IPC/CDP connection and is not logged or saved. A shared output controller accepts up to 32 queued text/control entries in press order, with ordinary pressed visuals and haptics. Subsequent output waits for the previous text or named-control acknowledgement. Characters and modifier mappings are captured at press time. Queued clicks retain their releases; already-delivered holds release immediately, and repeats begin only after delivery completes. Unsupported Steam-field shortcuts remain unsupported and never escape through local input.
A failed or uncertain delivery stops the queue. Failed text and consecutive queued text before the first native action remain for one Enter retry while the same typing session remains valid. Queued native actions and text after that boundary are discarded with a status. Each text entry stays within the compositor's 32-character limit; one retry advances all retained entries without merging across a discarded Tab or Enter. Target/request changes, route changes, cancellation, hiding, native fallback and shutdown clear the queue and retry buffer. Native fallback takes precedence over retaining a retry for an expired field. No timeout automatically resubmits text. A dispatch already in flight can finish after release or cancellation; these operations cannot retract a request already sent to Steam. A connection loss after delivery but before acknowledgement cannot establish whether the character reached the target. Check the field before retrying in that case.
Installation has its own two-second CDP deadline. The parent allows at most three seconds from helper launch through the first state reply, including connection and native-guard setup. Repeated frame packets cannot extend that startup budget, and the bridge cannot take over before a fresh readiness handshake. A timed-out attempt receives token-scoped cleanup even without an install acknowledgement. The install expression also checks its expiry before discovery and after hook creation, disposing a late result rather than publishing it. Python and Steam run on the same device and use its wall clock for those checks. If the connection is lost, the existing independent lease and orphan-hook disposal remain the recovery backstop. Discovery stops at the first valid keyboard store; no module cache persists between installations.
Ordinary CDP calls retain their 400 ms deadline, independent of the 300 ms connection timeout. Partial frames stay buffered across read timeouts, and late replies are discarded by command ID. One timed-out status poll skips that cycle; a timed-out text/key delivery reports failure and requests native fallback. Dismissal timeouts also request fallback. Three consecutive call timeouts end the helper. After the first state reply, the parent uses its normal 800 ms freshness limit. The Steam lease and runtime watchdog deadlines are unchanged.
Status polls run every 35 ms during a request, handoff or restoration. When the bridge explicitly reports that all of those are closed, the interval is 75 ms. Panel visibility alone cannot establish idle state. Missing or inconsistent idle metadata and timed-out polls keep the fast cadence; explicit fallback requests wake polling immediately. Input and parent packets remain independent of that timer. Watchdog deadlines and native-guard traffic are unchanged. The slower idle interval can add detection latency; it does not establish a battery-life improvement.
Steam keeps an independent 50 ms watchdog tick. During quiescence, a full inspection can share a recent helper or event inspection; without those inspections, the timer performs one after 100 ms. Busy sessions, pending restoration, clock reversal and expired leases with presentation suppression still force full inspection on the watchdog tick. Helper steps, status callbacks and presentation hooks always inspect directly. The independent lease and abandoned-install cleanup deadlines remain unchanged.
For local Gamescope recovery, the native guard uses a 1.2 second deadline measured from real app progress. This allows Steam's 900 ms presentation lease to expire first, so recovery cannot be mistaken for a new toggle. Explicit fallback restores immediately after withdrawing the Steam hook's logical session. On app/helper EOF or failure the guard restores the exact overlay. OpenVR closes a keyboard when its requesting client exits, so a guard that has restored native stays alive until the native session closes. It then exits. This is temporary request ownership, with no installed service or startup entry. X cancels recovery and keeps native closed.
Use Settings → Quit Full Keyboard for normal shutdown. If terminating from a shell, signal the app's exact PID rather than matching every process containing `framekeyboard`. A broad pattern such as `pkill -f framekeyboard` can terminate the independent native guard too. Steam's expiring hook still withdraws suppression, but automatic recovery of the previous local-app request cannot run without that guard. Press the native keyboard button again after suppression has expired. That new request remains native while Full Keyboard is stopped. The lease intervals are design deadlines, not guaranteed wall-clock recovery bounds during runtime stalls or headset sleep.
The host `timing-ordering` test reads the actual C++, Python and JavaScript timing
expressions and checks these required relationships. Missing or ambiguous source
matches fail the test, so a refactor cannot silently disable a check. Values remain
in their current source files; this adds no runtime configuration or timer changes.
| Required relationship | Current values | Purpose |
| --- | --- | --- |
| Bridge receipt/frame freshness and native-guard frame freshness < Steam lease | 700 ms < 900 ms | Stale app progress stops qualifying for takeover before the lease expires |
| Overlay hide < Steam lease | 700 ms < 900 ms | Withdraw our panel before native recovery |
| Request acknowledgement < Steam lease | 400 ms < 900 ms | Bound unacknowledged presentation within the lease |
| CDP call < each C++ text/key confirmation < helper reply freshness < Steam lease | 400 ms < 600 ms < 800 ms < 900 ms | Resolve or fail individual deliveries before broader session expiry |
| Steam lease < both native-guard arm/renew deadlines < ordinary parent recovery < declared slow-work ceiling | 900 ms < 1.2 s < 3 s < 30 s | Expire suppression before reopening native, and allow fallback before ordinary hang termination |
These are ordering checks. Scheduling delays, blocked runtime calls and suspend
still require the headset acceptance checks described above.
## Validation
Automated tests use fake Steam objects, fake input sinks and private socketpairs. They cover automatic-field metadata, same-window target changes, Unicode acknowledgement, failed commits, controller guarding, readiness deadlines, lease expiry, restoration, hook ownership and helper cleanup. A further 128 deterministic event sequences combine target changes, shared providers, missed close acknowledgements, pre-status shows, stale input, progress/readiness changes, dismissal and recovery. They check each unacknowledged request's deadline after time advances, expired-lease visibility, exclusive presentation, input rejection and restoration of original functions. Failure output includes a replayable seed and event trace. Recovery tests exercise bounded slow work, a stuck main loop with a live watchdog, signal escalation, helper exit and acquisition of the released instance lock by a new process. Actual process termination is confined to an isolated test executable with fake Steam/OpenVR interfaces. Host CJK suites may skip when their optional engines are absent; ARM64 tests run against the Frame's installed libraries.
Before relying on replacement, test in the headset with harmless text:
- Steam search and another Steam field that opens the keyboard automatically; dashboard keyboard button; desktop and floating local-app fields.
- Unicode, held Backspace, held printable keys, Enter, Tab, arrows, modifiers, Copy/Paste and local composition.
- No visible native keys or native movement handle, no flash on repeated summons, and no stock controller actions beneath our panel.
- X hides without exiting; the next native request reopens ours. Settings Quit exists only with replacement enabled.
- Use Steam keyboard, disable, explicit Settings Quit, crash, helper/CDP loss, backend loss, main-loop stall and full process suspension. Native must remain visible and clickable, including after repeated summon presses.
- Request changes while readiness or text is pending; Steam restart/update; app removal followed by native summons.
- With replacement enabled, headset sleep/wake and a SteamVR restart. Within the bounded recovery period, a responding runtime must not be mistaken for a permanent app hang. Verify native fallback and then a fresh Full Keyboard summon; runtime restart may still require manually relaunching the app if OpenVR disconnects.
This document defines prototype behavior and checks. It does not claim those physical acceptance checks have passed.
+43
View File
@@ -0,0 +1,43 @@
# Roadmap
Full Keyboard for Steam Frame is a manually launched alternative keyboard for local dashboard apps. Default behavior leaves the system keyboard untouched. An experimental, default-off replacement bridge handles supported native requests while the app runs. Streamed VR-app input is outside the project scope.
## Implemented
- Native ARM64 overlay, libei key delivery, desktop preview, and image export.
- Full-size US, international, and Japanese layouts with editable JSON profiles.
- European/Cyrillic XKB profiles, Brazilian ABNT2 geometry, integrated Japanese, Simplified/Traditional Pinyin and Korean two-set composition, plus external JIS mode.
- In-VR settings, theme selection, favorites, reload, and persistent configuration.
- Manual stable-release checks and verified, staged update/restart with rollback. See [updates](updates.md); future-release headset acceptance remains pending.
- Laser-trigger movement from a line below the keyboard, optional grip movement, same-hand stick depth in either controller mode, resize icons, and optional release-only horizon alignment, enabled by default.
- Dashboard position/rotation following and dashboard-only visibility/input.
- Saved placement, single-instance launches, and relaunch-to-recenter.
- Per-controller haptics, cancellation paths, host tests, and ARM64 release packaging.
## Before the first public release
- Independent code review and resolution of actionable findings.
- Review the [release checklist](releasing.md) and [manual acceptance matrix](acceptance.md).
- Confirm install/update/uninstall instructions from a fresh release archive.
- Review the public repository and package for private files and complete third-party notices.
- Publish a release with the ARM64 archive and checksums once review is complete.
## Experimental native replacement
- [x] Default-off setting, temporary bridge, request readiness and independent native fallback.
- [x] Resident panel dismissal with × and a separate Settings Quit shown only in replacement mode.
- [x] Optional Start with SteamOS, dependent on replacement, with hidden background startup.
- [x] Isolated request, timeout, target-switch, Unicode and cleanup regressions.
- [ ] In-headset automatic Steam-field, dashboard summon and zero-flash acceptance.
- [ ] Crash, hang, backend loss and sleep/wake acceptance with usable native recovery.
- [ ] Buffered requests and broader native target coverage.
## Further work
- Broader local-app, nested-desktop, and language testing.
- Fluent Japanese-user feedback on composition and candidate controls.
- Broader application testing for Unicode delivery and native shortcuts across system keymaps.
- Sleep/wake, runtime-update, and longer-session testing.
- Measured battery, memory, and latency results.
No universal native keyboard replacement, streamed PC input, speech recognition, swipe typing, or cross-session clipboard synchronization is promised. See [changelog](../CHANGELOG.md) for released behavior changes.
+31
View File
@@ -0,0 +1,31 @@
# Release checklist
The public name is **Full Keyboard for Steam Frame**; the launcher label is **Full Keyboard**. Keep executable names, config paths, overlay identifiers, and package names as `framekeyboard` for compatibility.
## Prepare
- Resolve independent-review findings and update the changelog.
- The project owner sets the version in `CMakeLists.txt` and updates README archive examples. Contributors must leave release versions unchanged; see [version ownership](../CONTRIBUTING.md#version-ownership).
- Run host tests and build the ARM64 package from a matching Frame sysroot.
- Run the safe ARM64 core tests on Frame and record the manual checks in [acceptance](acceptance.md). Do not turn untested items into claims.
- Regenerate the [README image](building.md#refresh-the-readme-image) if the default appearance changed.
- Validate a clean install, update, rollback, and uninstall; preserve user settings.
- Verify same-version reinstall and downgrade into fresh release directories, refusal while the instance lock is held, deletion only after activation/startup confirmation, and preservation of development/unmarked releases. Manual rollback reinstalls an older package; no permanent `previous` link is maintained. Keep a newer installer available to install packages whose bundled installer predates reinstall support.
- Check upgrade compatibility with the previous release's launcher arguments and environment. In-app updates preserve `~/.local/bin/framekeyboard` and the desktop entry, including user customizations; they do not run the new installer. If a release needs new launcher flags or environment variables, keep older launchers working or provide a migration before offering that release through the updater. A manual install refreshes the generated launcher and desktop entry.
- Verify the new package with the previous release's updater helper, which performs the installation before the new helper can run. Preserve the `framekeyboard-VERSION-aarch64.tar.gz` and `SHA256SUMS` asset names, the `framekeyboard-VERSION-aarch64` archive directory, executable `bin/framekeyboard` and `bin/framekeyboard-native-guard`, and at least one JSON profile in each of `layouts`, `languages` and `themes` under `share/framekeyboard`. Keep `--version` printing `framekeyboard X.Y.Z`, `--check --data-dir PATH --config-dir PATH`, and startup confirmation through `FRAMEKEYBOARD_UPDATE_READY_FD` with a single byte `1` within 45 seconds. Format or protocol changes require an upgrade path the old helper already understands.
- Preserve the `--autostart` flag and both startup preference booleans for installations using Start with SteamOS. Verify a hidden boot start, native summon, manual summon, opt-out and update handoff without killing independent helpers.
- Check that LICENSE, third-party notices, README, and docs are included in the archive.
- Inspect tracked files and Git history for private data before making the repository public. Do not publish local sysroots, user profiles, logs, backups, or credentials.
## Package and publish
```sh
export FRAMEKEYBOARD_SYSROOT=/absolute/path/to/frame-sysroot
./scripts/package.sh
```
Upload the generated `out/framekeyboard-VERSION-aarch64.tar.gz` and `out/SHA256SUMS` as assets for the matching tag. Use the archive basename from `SHA256SUMS`; do not rename it after hashing. The checksum file covers that release's install archive, not GitHub's generated source archives.
GitHub publication, repository naming, tags, and release visibility are separate deliberate steps. Preparing a local package does not publish it. No automatic upload workflow is configured.
Describe supported on-device use and known limitations in release notes. Link the README for installation and recovery. If the project has not yet completed a check, state that directly.
+122
View File
@@ -0,0 +1,122 @@
# Runtime and troubleshooting
## Launch and dashboard behavior
The installed **Full Keyboard** launcher uses:
```sh
framekeyboard --vr --input ei --start-enabled
```
It connects to the running Frame Gamescope compositor at `/run/user/<uid>/gamescope-0-ei`. The headset's VR session must already be running. The app does not start SteamVR or register autostart. Stock keyboard replacement is an experimental option, disabled by default. It is visible and accepts input only while the dashboard is open. Hiding cancels keys, unfinished composition, and grabs.
First launch starts at 95 cm wide, tilted back 50° from upright, just beneath the dashboard. If the dashboard anchor is unavailable, it falls back to 65 cm below eye level and 85 cm forward. Placement follows dashboard position and full rotation, including when a local app tab is selected.
Quitting saves the pose, size, and dashboard reference. Reopening restores them. Launching an already running instance recenters it and preserves size; it also returns from Settings to the keys. A launch while the dashboard is hidden queues recentering for its next opening. Later launch flags do not change the running instance's backend or language. Quit it before changing those options.
When Experimental → Replace native keyboard is enabled, × dismisses the current typing session and hides the panel while the app stays resident. The next supported native keyboard request reopens our panel. **Quit Full Keyboard** is available only in Settings while replacement is enabled; use it for a full exit before updates, removal or changing launch options. With replacement disabled, × exits normally. See [native replacement](native-replacement.md).
A temporary compositor pause releases held keys and resumes typing when its keyboard device returns. After a disconnect, native and Unicode connections retry in the background with a 1-second delay doubling up to 30 seconds. Old holds and text are never replayed. The "Keyboard paused or disconnected" status remains visible even if the interruption happened while the dashboard was closed. OpenVR session loss can still require a relaunch.
Tracking loss hides the keyboard and releases input. When tracking returns, the panel returns to its pose. A tracking-origin reset or a known saved tracking-space mismatch recenters it while retaining size. Corrupt saved placement is ignored. Forced termination cannot save the latest movement.
Keys have no fixed hold deadline: holding Shift with one hand or repeating Backspace can continue beyond ten seconds. A known controller losing tracking or leaving the panel cancels only its pointer; hiding, application-focus loss, and shutdown release all keys.
## Movement and feedback
By default, point at the line below the keyboard, hold the trigger, move your hand, then release to place it. The line matches the native dashboard handle: dark gray at rest, white and 10% wider while hovered or held, with an 80 ms transition. To use the original grip movement, enable Use Drag to Move in the General settings card and Apply. In that mode the line is hidden; point at the keyboard and squeeze grab/grip to move and rotate it. While holding the movement line or grabbing, that hand's thumbstick moves it farther with up and closer with down. Line movement does not require holding grip. Grip must be released once after launch or a controller reconnect. Either controller can start movement, and only that pointer owns it until release. The AUX head cursor can also capture the line: aim with your head, hold AUX, move your head and release. Head tracking supplies its pose; haptics remain controller-only. Pinning disables both methods and hides the line. Tracking loss, hiding, recentering, or a 30-second movement timeout restores the typing state.
The keyboard reads grip travel from the native Frame controller model. Other controller models are unsupported. Steam updates that change this model may require an app update. No global SteamVR input setting is changed.
General → Auto leveling is on by default. Horizon assistance runs only after release: a roll within 5° eases level over 500 ms without changing pitch. Disable Auto leveling and Apply to retain your chosen roll. Changing the setting preserves the current displayed pose. Toolbar size controls change width by 5 cm within 45 cm to 2 m.
Press feedback uses 25 ms at full strength; release uses 8 ms at 35%, both at 150 Hz. Hover uses 4 ms at 10% and 240 Hz. Feedback goes to the controller responsible for the interaction. Toolbar actions, repeats, and dragging do not pulse.
## Language and input routing
Choose a language in Settings and Apply and save. The normal `--input ei` path
sends locally resolved Unicode characters, so system layouts and launch-time
language declarations do not control typing. Native Enter, arrows and shortcuts
remain available. See [language selection](languages.md).
Only the developer uinput backend and external Japanese JIS mode require a
matching target declaration. For an existing Japanese system keymap and IME,
select the JIS preset and launch with `--target-language ja-jis`. The optional
`FRAMEKEYBOARD_TARGET_LANGUAGE` launcher variable exists for that physical mode.
Unicode text follows the matching Wayland socket: for example, `/run/user/1000/gamescope-2-ei` pairs with `/run/user/1000/gamescope-2`. Relative names resolve under `$XDG_RUNTIME_DIR` in both backends. For nonstandard names, add `--text-socket PATH` explicitly; otherwise Unicode text is unavailable rather than being sent to the default compositor. `--text-socket` requires `--input ei`. Close the running keyboard before changing launch options, since launching a second instance only recenters the first.
For example, to address a different running compositor:
```sh
~/.local/bin/framekeyboard --vr --input ei --start-enabled \
--ei-socket gamescope-2-ei
```
The optional `--input uinput` developer backend requires access to `/dev/uinput`; newly created devices were not discovered by the tested outer VR session. The installed launcher uses libei and does not need this backend.
Copy/Paste send Ctrl+C/Ctrl+V. They do not synchronize clipboards between sessions, implement terminal Ctrl+Shift shortcuts, or read clipboard data. In Unicode mode Caps/Num Lock are local to this panel and do not change the system locks. Native modifier shortcuts remain subject to receiving-app behavior.
## Troubleshooting
| Symptom | What to check |
| --- | --- |
| Keyboard is missing | Open the dashboard, wake the headset, then launch Full Keyboard again to recenter |
| Keyboard is offscreen | Relaunch while it is running, or use the recenter icon |
| App exits immediately | Run `~/.local/bin/framekeyboard` in a terminal and read the error; check that the VR session is running |
| Keys do not reach a local app | Focus a harmless text field first; check the Unicode text connection and chosen profile |
| Keyboard paused or disconnected | Wait for the compositor; native and Unicode connections retry in the background and old holds are cleared |
| Input connection was lost | Wait for background reconnect; old holds and text are discarded. For an unrecoverable resident failure, use Settings → Quit Full Keyboard, then relaunch |
| Wrong characters | Check the selected app language and custom XKB profile; physical uinput/external-JIS modes additionally need a matching target keymap |
| Copy/Paste fail in a terminal | This version sends Ctrl+C/Ctrl+V, not terminal Ctrl+Shift shortcuts |
| Grip does nothing | Release it fully, point at the keyboard, then squeeze; wake the controller and verify it is a Frame controller |
| Japanese only previews text | Check Anthy, fonts, the native text protocol, and the selected mode; see [Japanese input](japanese.md) |
| Installer says Full Keyboard is running | Quit Full Keyboard in Settings, then run the installer again |
| New name is absent in Steam Library | A manually added shortcut has its own title; rename it to Full Keyboard in its Properties |
No input is sent outside the dashboard. Streamed VR applications, including VRChat, are outside the supported scope. Focus behavior in nested desktops and floating apps needs testing per application. Do not assume success in a dedicated receiver proves every app works.
## Update, rollback, and removal
Use the centered Settings → Check for updates button for a verified download, installation and restart. See [updates](updates.md) for button states, requirements and recovery.
For a manual installation, quit Full Keyboard first. The installer holds the instance lock throughout installation and refuses if the app is running. It validates the copied ARM64 binary and profiles in a fresh staging directory before switching `current`, then removes the replaced production release. Settings under `~/.config/framekeyboard` are preserved. The same version can be reinstalled, and older versions can replace newer ones. A failed preparation leaves the current release usable; interrupted cleanup never deletes the release named by `current`.
To roll back, download an older release archive and its checksums, verify it and extract it as described in the README. Before rolling back to a release without Start with SteamOS, disable that option and apply it first. Quit Full Keyboard, then reinstall the older package. For an older package whose installer predates reinstall support, use the installer from a newer extracted package and pass the older package directory:
```sh
bash /path/to/newer-package/install.sh /path/to/extracted-older-package
~/.local/bin/framekeyboard
```
Production installs keep no permanent rollback release or `previous` link. During an in-app update, the old release and preference snapshot survive until the new process confirms startup; a failed start restores the old release automatically. Cleanup only removes directories positively marked by the production installer/updater. Development deployments, historical unmarked releases and unrelated `previous` links stay untouched. Older installers/updaters keep their original retention behavior. The installed app label stays Full Keyboard when reinstalling an older binary.
To uninstall, first uncheck Start with SteamOS and Apply and save, then quit the app. If the app cannot run, remove its optional startup unit first:
```sh
systemctl --user disable --now framekeyboard-autostart.service
rm -f ~/.config/systemd/user/framekeyboard-autostart.service
systemctl --user daemon-reload
```
Then run:
```sh
rm ~/.local/bin/framekeyboard
rm ~/.local/share/applications/framekeyboard.desktop
rm -r ~/.local/share/framekeyboard
```
Keep `~/.config/framekeyboard` if you want to preserve themes, layouts, and placement. If you added a Steam Library shortcut manually, remove it through Steam too. No OS or stock keyboard files need restoring. The optional user-local startup unit is removed as described above.
## Developer diagnostics
Desktop preview and PNG export never inject input. A direct `--vr` binary launch also starts input-disabled unless a backend and `--start-enabled` are supplied. Normal compositor input does not require `--target-language`; physical uinput and external Japanese JIS mode still require a matching target declaration. For an input-disabled installed VR check, close any running instance first, then use:
```sh
~/.local/bin/framekeyboard --vr --input none --duration 4 --config-dir /tmp/full-keyboard-smoke
```
A second launch only recenters the first instance; it cannot turn an active typing session into a safe test session. Core CTest suites use fake sinks. Input probes are opt-in and require a dedicated receiver or their own exclusively grabbed device. See [building and tests](building.md).
See [languages and system keyboard settings](languages.md) for all profile IDs, Chinese/Korean controls, and the explicit limitations when multiple system layouts are configured.
+43
View File
@@ -0,0 +1,43 @@
# Updates from Settings
The centered **Check for updates** button in the Settings header checks the newest stable release from [this project's GitHub releases](https://github.com/TaiKeid/steam-frame-full-keyboard/releases). There are no automatic checks. Python 3.12 or newer, HTTPS access and a normal versioned ARM64 installation are required to install. The feature uses Python's standard library and adds no service or listening port.
If the published version is equal to or older than the running version, the button says **You are on latest version** for five seconds, then returns to **Check for updates**. Versions are compared numerically, so 0.10.0 is newer than 0.9.0. Drafts and prereleases are excluded.
When a newer stable release is available, **Download and install update** stays available until the process exits, including across closing/reopening Settings. Pressing it starts the update. Checking alone never downloads or installs a package. During an operation, duplicate presses do nothing. If checking or preparation fails, **Update failed. Try again** appears for five seconds, with a short reason in the Settings status line. Reasons distinguish network errors, GitHub request limits, checksum failures, another installer, disk/permission errors and invalid packages. A known update remains offered after a failed install.
## Verification and installation
A private helper handles all network, hashing, archive and installation work. The VR loop exchanges bounded nonblocking packets and keeps rendering/input responsive. Requests go to the fixed public GitHub repository over certificate-verified HTTPS, without credentials or proxy-environment forwarding. Asset URLs must match the exact release/tag/name. Redirects are restricted to HTTPS GitHub release download hosts. Requests, sizes and unpacked files are bounded.
Each stable release must publish `framekeyboard-VERSION-aarch64.tar.gz` and `SHA256SUMS`. The helper requires exactly one checksum entry for that exact archive and verifies SHA-256 before extraction. If GitHub also provides an asset digest, it must agree. This trusts the project's GitHub release and protects against download corruption; it is not a separate signature system.
The current updater accepts GitHub release publishing access as its trust boundary. Someone who can publish a release can provide both a different archive and matching checksums; the API digest is not an independent approval of that publisher. HTTPS and checksum verification do not protect against that case. Independent release signing is deferred. Adding it would require a separately protected signing key, a public key trusted by installed clients, and a backup, rotation and migration plan. Verification would need to succeed before extraction or activation.
Extraction rejects traversal, absolute paths, links, devices, duplicate names and special permission bits. Before closing the keyboard, the helper takes the existing installation lock, extracts into a private directory under the installation's `releases/`, verifies the AArch64 ELF binaries, checks the package version and validates copied profiles with an isolated configuration. No downloaded installer script is executed.
The keyboard then releases held input, withdraws native replacement, saves settings/placement and closes VR and its instance lock. Only an explicit handoff after cleanup authorizes activation. Merely closing/crashing the app cancels unfinished work. The helper waits for the original process to exit, snapshots the saved config and placement, atomically updates `current`, and starts the new executable with the original launch options and the new data directory. The new process acknowledges three seconds of completed-loop progress through a private inherited socket. A gap over one second resets this probation. Hidden panels count normal idle loops, so a closed dashboard or unavailable native replacement cannot prevent confirmation. The helper allows 45 seconds for startup. If startup fails or times out, the helper terminates only that new child, restores `current` and saved preferences and relaunches the old executable. Confirmation covers this startup interval, not every later interaction; native replacement retains its independent fallback rules.
User settings, placement, custom profiles, launchers, desktop entries and autostart are preserved. After startup confirmation and any required service adoption, the helper removes the replaced production release and this update's config/placement snapshot. Cleanup failure leaves the new app running and reports remaining files in `update-status.log`; it never rolls back a confirmed process. Failed updates retain their snapshots under `update-backups/` for recovery. [Manual rollback](runtime.md#update-rollback-and-removal) means reinstalling an older release package.
Production installers and updaters mark their release directories with a root/name-bound ownership record. Cleanup refuses the active `current`, symlinked releases, and directories without a matching marker, including development deployments and releases installed before this policy. No `previous` link is created or updated; a leftover link is removed only when it names the marked release just deleted. Automatic installations use a unique version-prefixed release directory, so an existing installation is never overwritten. Short, fixed results and failure codes are saved to `~/.local/share/framekeyboard/update-status.log`, including check/download/validation failures before restart. Failure reporting also uses normal stderr, including for launches outside a versioned installation. Exception text, URLs, network responses, input text and clipboard content are never included in these reports.
The helper and any restarted app inherit the original app's stdout and stderr destinations. An update therefore preserves normal launcher or terminal logging, including after rollback. Stdin remains disconnected, and no additional log service or debug interface is installed.
The boot-only `--autostart` option is omitted from update, rollback and failed-handoff restarts. These are explicit user-requested launches, so turning off Start with SteamOS or replacement during a session cannot prevent them from starting. Saved startup preferences and the session unit are preserved.
A prepared update closes the outgoing replacement request without reopening Steam's keyboard. A service-managed successor starts hidden while replacement remains enabled, confirms startup in the background and waits for a fresh keyboard request. This presentation policy is separate from boot opt-in, so disabling Start with SteamOS does not block an update or rollback. Manual updates, and updates with replacement disabled, still show the panel. Quit, disable and failures retain native recovery.
Updates initiated by published 0.8.0 or earlier still use that version's outgoing cleanup, which can reopen native once. Dismiss it; the next request can summon Full Keyboard. Installing a fixed successor cannot change the code that the old version already ran.
When running under the optional startup service, cleanup still releases VR, input and the instance lock before handoff, but the original service process stays alive. The helper verifies that the service owns that process, launches the candidate and waits for the same sustained startup confirmation. It then uses the user manager's local notification socket to transfer MainPID to the confirmed successor and checks that the manager accepted it. Only then does the original process exit. The startup unit accepts notifications from its own service processes; no network port is involved. A failed candidate is terminated and rollback applies the same adoption check to the previous release. If neither child can be adopted, both are stopped and the original main process restarts its own still-known binary. Manual launches retain the ordinary exit-then-launch protocol.
## Validation
In-app updates preserve the existing launcher and desktop entry and restart with the current launch arguments. They do not apply changes in a newer install script. Release preparation must verify compatibility with older launchers; see the [release checklist](releasing.md#prepare). Manual installation refreshes the generated launcher and desktop entry.
Offline tests use fake release metadata, capture channels and disposable installs. They cover numeric comparison, exact checksum matching, hostile archives, architecture/profile rejection, install locking, cancellation, restart acknowledgement, startup failure/rollback and button timing. Real download checks and ARM64 runtime validation are separate from those offline fixtures. A future release is needed for normal in-headset upgrade acceptance; changing the version is reserved for the project owner.
Rollback attempts link repair, each saved preference and the old executable independently. A file error does not skip the other recovery steps or the old-app restart. The downloaded archive is removed first to free space; the promoted release is removed only after `current` is confirmed to name the old release. If writes remain unavailable, the new target is retained so the launcher never points at a deleted directory, and the helper directly starts the known old binary. The fixed status log reports incomplete recovery writes; preference backups remain available for repair.
Once the original app has exited, termination signals are deferred until activation commits or rollback finishes. They are then replayed outside the recovery handler. Child processes retain normal signal handling. Acknowledged startup commits the new release before optional cleanup and status logging, so a late stop cannot delete its files or relaunch the old app while the new process owns the instance lock.
+35
View File
@@ -0,0 +1,35 @@
# VR lifecycle requirements
`VrLifecycle` coordinates the existing presentation and tracking policies. The
OpenVR shell supplies runtime observations and effects. Input state, rendering,
pose geometry and independent watchdogs retain their existing owners.
The host acceptance scenarios exercise these requirements through real App input
and private receivers. They do not require a particular list of internal states
or callbacks.
1. Changing the typing target releases held keys to the old receiver and discards
queued input before the new receiver is selected. This applies even when both
receivers use the same transport. An unchanged target must not interrupt a hold.
2. A hidden dashboard or panel cannot deliver pointer input, repeats or queued
text. Hiding cancels movement and pointer captures. Reopening permits fresh
input, without replaying the cancelled work.
3. Closing the panel dismisses the current request while the process remains
resident. Late status for that request cannot reopen or acknowledge it. A
fresh request may open the panel. Choosing native fallback has the same
request isolation but asks Steam to preserve native input rather than dismiss.
4. A request is acknowledged only after successful presentation with usable
placement/tracking, an open dashboard, actual compositor visibility and a
ready input backend. Replacement typing waits for takeover acknowledgement.
A failed presentation cannot consume a pending show or advertise success.
5. Recenter/show requests survive temporarily closed dashboards and missing
tracking. Tracking recovery restores readiness without reviving a dismissed
or expired request. Background startup stays hidden until requested.
6. Focus or tracking-space loss cancels input and movement. Losing one pointer
device releases that device's keys without releasing the other hand. A lost
movement owner ends movement; an unrelated device loss or laser leave does
not end a valid movement capture.
These checks complement the existing output, bridge, movement and tracking tests.
They cannot establish physical OpenVR presentation or controller behavior. Runtime
timings, watchdogs, frame pacing and independent native recovery remain unchanged.
+28
View File
@@ -0,0 +1,28 @@
# Template: the installer replaces @REPO@ with the repo path on the Frame.
[Unit]
Description=Frametop keyboard: Full Keyboard for Steam Frame, by TaiKeid (keyboard/FRAMETOP.md)
Documentation=file://@REPO@/keyboard/FRAMETOP.md https://github.com/TaiKeid/steam-frame-full-keyboard
# Needs SteamVR's IPC; it starts and stops with SteamVR.
After=steamvr.service frametop-input-relay.service
PartOf=steamvr.service
Requisite=steamvr.service
[Service]
# SteamOS's Mesa settings, as Full Keyboard's own startup unit has them.
EnvironmentFile=-/usr/share/deckard/mesavars.sh
# Runs in the container it was built in (scripts/in-box, which starts it in a scope of its
# own), so clean it up explicitly around it. Its layouts, themes and languages come from
# the source folder.
ExecStartPre=-/usr/bin/pkill -x framekeyboard
ExecStart=@REPO@/scripts/in-box @REPO@/keyboard/build/frametop/framekeyboard --frametop --data-dir @REPO@/keyboard
ExecStopPost=-/usr/bin/pkill -x framekeyboard
# Quitting it from its own panel exits with 3: it stays closed until SteamVR starts again.
SuccessExitStatus=3
RestartPreventExitStatus=3
Restart=on-failure
RestartSec=3
# The container session (conmon) ignores SIGTERM; don't wait 90 s for it.
TimeoutStopSec=5
[Install]
WantedBy=steamvr.service
+201
View File
@@ -0,0 +1,201 @@
#pragma once
#include "cjk.hpp"
#include "instance.hpp"
#include "japanese.hpp"
#include "output.hpp"
#include "panel.hpp"
#include "placement.hpp"
#include "settings_ui.hpp"
#include "settings_writer.hpp"
#include <atomic>
#include <csignal>
#include <optional>
#include <utility>
namespace framekeyboard {
class NativeReplacement;
class FrametopLink;
class Updates;
enum class Mode { Help, Preview, Vr, Render, RenderSettings, Check, Describe };
enum class InputBackend { None, Ei, Uinput };
struct Options {
fs::path data_dir, config_dir, ei_socket, text_socket;
Mode mode{Mode::Help};
InputBackend input{InputBackend::None};
std::string output, target_language;
double duration{};
bool start_enabled{false};
bool autostart{};
bool background_update{};
// Run as Frametop's keyboard (FRAMETOP.md): resident, hidden until summoned, no
// self-update or SteamOS startup of its own.
bool frametop{};
};
class App {
public:
App(const Options& options, KeySink& sink, SettingsWriter::Write write_preferences = {},
Updates* updates = nullptr, SettingsWriter::Write reconcile_preferences = {});
PanelView view() const;
// True only for a newly accepted keyboard key, so VR can provide haptics.
bool down(unsigned pointer, double x, double y, double now);
// True when an unpressed pointer enters a different keyboard key.
bool move(unsigned pointer, double x, double y);
// True when this pointer releases a captured keyboard key, even outside it.
bool up(unsigned pointer, double x, double y);
bool pointer_pressed(unsigned pointer) const {
return keyboard_.pointer_pressed(pointer) || pressed_controls_.contains(pointer) ||
settings_ui_.pressed(pointer);
}
void cancel_pointer(unsigned pointer);
void cancel(bool discard_composition = true);
bool tick(double now);
void paint(double now);
void show_settings();
void back();
bool scroll(unsigned pointer, double dx, double dy);
bool pinned() const { return settings_.pinned; }
bool use_drag_to_move() const { return settings_.use_drag_to_move; }
bool auto_leveling() const { return settings_.auto_leveling; }
bool replace_native_keyboard() const { return settings_.replace_native_keyboard; }
bool start_with_steamos() const {
return settings_.replace_native_keyboard && settings_.start_with_steamos;
}
bool background_start() const {
return options_.autostart || options_.frametop ||
(options_.background_update && settings_.replace_native_keyboard);
}
bool frametop() const { return options_.frametop; }
bool replacement_ready() const;
bool take_native_fallback();
bool resident_replacement() const {
return options_.mode == Mode::Vr && (settings_.replace_native_keyboard || options_.frametop);
}
void close_panel();
bool take_dismissal();
void set_replacement_available(bool available, std::string reason = {}) {
if (available) {
reason.clear();
}
dirty |= replacement_available_ != available || replacement_unavailable_reason_ != reason;
replacement_available_ = available;
replacement_unavailable_reason_ = std::move(reason);
}
void set_native_session(bool open) {
dirty |= native_session_ != open;
native_session_ = open;
}
bool allows_movement(MoveSource source) const {
return !pinned() && use_drag_to_move() == (source == MoveSource::Grip);
}
void summon();
void set_dragging(bool dragging);
// Hiding the VR context releases keys before closing the input gate.
void set_interaction_active(bool active);
void report_status(const std::string& message);
std::vector<PlacementAction> take_placement_actions();
void apply(Selection selection);
// Reload immediately when storage is idle, otherwise coalesce the request
// and complete it from tick() after the newest pending snapshot is saved.
void reload();
void flush_settings();
// Only explicit profile/storage work gets extra time. The deadline stays
// fixed until a complete VR frame, even across nested or repeated work.
double slow_work_deadline() const { return slow_work_deadline_.load(); }
void frame_completed() { slow_work_deadline_.store(0); }
const fs::path& config_dir() const { return options_.config_dir; }
bool quitting() const { return quit_; }
bool updating() const;
bool take_recenter();
bool dirty{true};
PanelRenderer renderer;
const Selection& selection() const { return settings_.active; }
private:
std::vector<Control> controls() const;
void update_settings_ui();
void activate(Selection selection);
void save();
void update_save_status();
void allow_slow_work();
void rebuild_cycle_entries();
void action(const ControlAction& action);
void refresh_typing();
bool japanese_key(const Key& key, bool execute, const std::set<int>& mods);
void commit_japanese();
void convert_japanese();
bool japanese() const;
bool cjk_key(const Key& key, bool execute, const std::set<int>& mods);
void commit_cjk();
void choose_cjk(int index);
bool unicode_mode() const;
bool text_key(const Key& key, const std::set<int>& mods) const;
int native_code(const Key& key, const std::set<int>& mods);
void update_output_status();
bool retry_output();
Updates* updates_{};
Options options_;
Profiles profiles_;
Settings settings_;
SettingsWriter settings_writer_;
std::string save_status_;
bool save_status_is_startup_{};
bool reload_requested_{};
Selection pending_, default_;
// Only explicit Apply changes the saved choice. Startup fallback and
// favorite cycling affect the runtime selection/default independently.
Selection persisted_active_;
std::string startup_status_;
std::vector<Selection> cycle_entries_;
std::vector<Favorite> pending_favorites_;
bool pending_hide_numpad_{};
bool pending_use_drag_to_move_{};
bool pending_auto_leveling_{true};
bool pending_replace_native_keyboard_{};
bool pending_start_with_steamos_{};
bool native_fallback_{};
bool replacement_available_{};
std::string replacement_unavailable_reason_;
bool native_session_{};
bool dismiss_requested_{};
SettingsUi settings_ui_;
std::unique_ptr<LanguageMap> keymap_;
// The launch declaration is frozen; Reload cannot redefine the target.
std::optional<Language> target_language_;
OutputController output_;
KeyboardState keyboard_;
JapaneseComposer composition_;
std::unique_ptr<CjkComposer> cjk_;
bool cjk_latin_{};
bool japanese_latin_{};
bool text_ready_{};
bool typing_ready_{};
struct HoverTarget {
enum class Kind { None, Key, Control } kind{Kind::None};
std::string id;
bool operator==(const HoverTarget&) const = default;
};
std::map<unsigned, HoverTarget> hovered_;
struct PressedControl {
std::string id;
ControlAction action;
};
std::map<unsigned, PressedControl> pressed_controls_;
std::string status_;
bool settings_open_{false}, quit_{false}, recenter_{false};
std::vector<PlacementAction> placement_actions_;
std::map<unsigned, std::pair<double, double>> pointer_positions_;
bool dragging_{};
bool interaction_active_{true};
bool backend_ready_{true}, backend_can_resume_{};
std::optional<bool> native_num_requested_;
std::atomic<double> slow_work_deadline_{};
};
double monotonic_seconds();
extern volatile std::sig_atomic_t interrupted;
int run_preview(App& app, double duration);
int run_vr(App& app, VrInstance& instance, NativeReplacement& replacement, double duration,
FrametopLink* frametop = nullptr);
} // namespace framekeyboard
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
namespace framekeyboard {
// Local Pinyin/Hangul composition. The app owns delivery and only clears this
// state after a successful text commit. No engine learning API is used.
class CjkComposer {
public:
explicit CjkComposer(const std::string& method);
~CjkComposer();
void type(char key);
void backspace();
void cancel();
void cycle(int direction);
void choose(int index);
std::string preedit() const;
std::string text() const;
std::vector<std::string> candidates() const;
int selected() const;
bool empty() const;
bool has_reading() const;
bool chinese() const;
static bool supported(const std::string& method);
private:
struct State;
std::unique_ptr<State> state_;
};
} // namespace framekeyboard
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include "layout.hpp"
#include <filesystem>
#include <map>
#include <string>
#include <vector>
namespace framekeyboard {
namespace fs = std::filesystem;
struct Color {
bool operator==(const Color&) const = default;
double r{}, g{}, b{};
};
struct Theme {
bool operator==(const Theme&) const = default;
std::string id, name;
Color surface, top, middle, bottom, side_top, side_bottom, legend;
Color hover, latched, disabled;
double surface_radius{}, radius{}, depth{}, travel{}, duration_ms{};
double font_size{20}, small_font_size{13}, padding{20};
};
struct Language {
bool operator==(const Language&) const = default;
std::string id, name, locale, rules, model, keymap, variant, options, font;
std::string input_method{"xkb"};
std::map<std::string, std::string> kana, kana_shift;
std::map<std::string, std::string> composition_keys, composition_shift;
std::map<std::string, std::string> overrides;
std::vector<std::string> required_keys;
};
struct Selection {
bool operator==(const Selection&) const = default;
std::string layout{"en-us-full"}, language{"en-us"}, theme{"graphite"};
};
struct Favorite {
std::string id, name;
Selection selection;
};
struct Profiles {
std::map<std::string, Layout> layouts;
std::map<std::string, Language> languages;
std::map<std::string, Theme> themes;
std::map<std::string, std::string> sources;
std::vector<std::string> errors;
};
struct Settings {
Selection active;
std::vector<Favorite> favorites;
bool pinned{};
bool num_lock{};
bool hide_numpad{};
bool use_drag_to_move{};
bool auto_leveling{true};
bool replace_native_keyboard{};
bool start_with_steamos{};
};
fs::path default_config_dir();
// Defaults are embedded. Reload may retain a previous valid user override on errors.
Profiles load_profiles(const fs::path& data_dir, const fs::path& user_dir,
const Profiles* previous = nullptr);
Settings load_settings(const fs::path& user_dir, std::vector<std::string>& errors);
void save_selection(const fs::path& user_dir, const Selection& selection);
void save_settings(const fs::path& user_dir, const Settings& settings);
void validate_selection(const Profiles& profiles, const Selection& selection);
// Geometry/profile checks for callers that prepare the XKB map themselves.
void validate_selection_profiles(const Profiles& profiles, const Selection& selection);
Layout parse_layout(const std::string& json);
Theme parse_theme(const std::string& json);
Language parse_language(const std::string& json);
} // namespace framekeyboard
@@ -0,0 +1,56 @@
#pragma once
#include "input.hpp"
#include "text_input.hpp"
#include <libei.h>
namespace framekeyboard {
// Infer only the documented gamescope-N-ei naming convention. Unknown names
// require an explicit text socket; an empty result disables text delivery.
fs::path gamescope_text_socket(const fs::path& input_socket, const fs::path& explicit_text = {});
// Connect directly to the running Gamescope compositor. This avoids the
// SteamVR startup-time discovery limitation of a newly created uinput device.
class EiSink : public KeySink {
public:
explicit EiSink(const fs::path& socket, const fs::path& text_socket = {},
GamescopeText::Clock clock = {});
~EiSink() override;
void send(int code, int value) override;
bool pump() override;
bool take_input_reset() override;
bool can_resume() const override { return true; }
bool text_available() override;
bool commit_text(const std::string& text) override;
bool text_pending() const override { return text_ && text_->pending(); }
bool output_blocked() const override { return text_ && text_->busy(); }
void cancel_text() override {
if (text_) {
text_->cancel();
}
}
int shortcut_code(xkb_keysym_t symbol, int fallback) override;
std::optional<bool> num_lock_state() override { return num_lock_; }
EiSink(const EiSink&) = delete;
EiSink& operator=(const EiSink&) = delete;
private:
void read_keymap();
void service_text();
bool connect();
void connection_lost();
std::unique_ptr<xkb_context, decltype(&xkb_context_unref)> xkb_{nullptr, xkb_context_unref};
std::unique_ptr<xkb_keymap, decltype(&xkb_keymap_unref)> keymap_{nullptr, xkb_keymap_unref};
xkb_layout_index_t group_{};
std::optional<bool> num_lock_;
fs::path socket_, text_socket_;
std::unique_ptr<GamescopeText> text_;
GamescopeText::Clock clock_;
double text_retry_at_{}, text_retry_delay_{1};
double retry_at_{}, retry_delay_{1}, connect_deadline_{};
ei* context_{};
ei_device* keyboard_{};
bool resumed_{}, disconnected_{}, input_reset_{};
std::uint32_t sequence_{};
std::set<int> held_;
};
} // namespace framekeyboard
@@ -0,0 +1,69 @@
#pragma once
#include <map>
#include <utility>
#include <vector>
namespace framekeyboard {
enum class KeyFeedback { Hover, Press, Release };
struct HapticPulse {
float duration, frequency, amplitude;
};
constexpr HapticPulse key_pulse(KeyFeedback feedback) {
// Equal API values felt stronger on release on Frame. Keep its pulse
// shorter so the perceived click can match the press.
switch (feedback) {
case KeyFeedback::Hover:
return {.004f, 240.f, .1f};
case KeyFeedback::Press:
return {.025f, 150.f, 1.f};
case KeyFeedback::Release:
return {.008f, 150.f, .35f};
}
return {};
}
class KeyHaptics {
public:
void hover(unsigned device) { pending_.try_emplace(device, KeyFeedback::Hover); }
void press(unsigned pointer, unsigned device) {
owners_[pointer] = device;
pending_[device] = KeyFeedback::Press;
}
void release(unsigned pointer) {
const auto owner = owners_.find(pointer);
if (owner != owners_.end()) {
pending_[owner->second] = KeyFeedback::Release;
owners_.erase(owner);
}
}
void cancel_pointer(unsigned pointer) {
if (const auto owner = owners_.find(pointer); owner != owners_.end()) {
pending_.erase(owner->second);
owners_.erase(owner);
}
}
void cancel() {
owners_.clear();
pending_.clear();
}
std::vector<std::pair<unsigned, KeyFeedback>> take(double now) {
std::vector<std::pair<unsigned, KeyFeedback>> result;
for (const auto& [device, kind] : pending_) {
// A hover arriving in the next frame must not extend a click.
if (kind == KeyFeedback::Hover && now < hover_after_[device]) {
continue;
}
result.emplace_back(device, kind);
if (kind != KeyFeedback::Hover) {
hover_after_[device] = now + key_pulse(kind).duration;
}
}
pending_.clear();
return result;
}
private:
std::map<unsigned, unsigned> owners_;
std::map<unsigned, KeyFeedback> pending_;
std::map<unsigned, double> hover_after_;
};
} // namespace framekeyboard
@@ -0,0 +1,79 @@
#pragma once
#include "input.hpp"
#include <functional>
#include <map>
#include <string>
namespace framekeyboard {
// Frametop integration (FRAMETOP.md). Frametop runs Full Keyboard as its keyboard: its
// input relay says where typing goes (the Frametop desktop or Steam) and when the panel
// should open for a desktop text field, and takes the desktop's typing.
//
// Datagrams on the relay's abstract socket @frametop_relay; it replies to our autobound
// address. We send "kbhello OPEN" (the panel is up for Frametop 0|1) every second and on a
// change, "kbkey CODE 0|1" and "kbtext TEXT". It sends "kb DESKTOP SURFACE" (typing goes
// to the desktop 0|1, the desktop's screens show and Frametop isn't paused 0|1) in reply
// and on a change, and "kb show|hide|toggle".
// Only messages from that name and this user count, and typing goes there only while
// its word is fresh, so a stopped relay never leaves desktop typing addressed to a name
// another process could take.
class FrametopLink {
public:
using Clock = std::function<double()>;
explicit FrametopLink(std::string relay_name = "frametop_relay", Clock clock = {});
~FrametopLink();
FrametopLink(const FrametopLink&) = delete;
FrametopLink& operator=(const FrametopLink&) = delete;
enum class Request { None, Show, Hide, Toggle };
// Read what the relay sent, and say hello when it's time. Never blocks.
void poll();
bool fresh() const;
bool desktop() const { return fresh() && desktop_; }
bool surface() const { return fresh() && surface_; }
Request take_request();
// Whether the panel is up for Frametop: said in the next hello, at once on a change.
void set_open(bool open);
bool send(const std::string& message);
int fd() const { return fd_; }
static constexpr double stale_after = 3;
private:
std::string relay_;
Clock clock_;
int fd_{-1};
double heard_{-1e9}, hello_{-1e9};
bool desktop_{}, surface_{}, open_{};
Request request_{Request::None};
};
// Desktop typing goes to the relay (which types it on the desktop's seat, in the desktop's
// layout); everything else goes to `steam` (gamescope). A held key is released where it
// was pressed, whichever way typing goes by then.
class FrametopSink : public KeySink {
public:
FrametopSink(FrametopLink& link, KeySink& steam) : link_(link), steam_(steam) {}
~FrametopSink() override;
void send(int code, int value) override;
bool pump() override;
bool take_input_reset() override { return steam_.take_input_reset(); }
bool can_resume() const override { return steam_.can_resume(); }
bool text_available() override { return link_.desktop() || steam_.text_available(); }
bool commit_text(const std::string& text) override;
bool text_pending() const override { return steam_.text_pending(); }
void cancel_text() override { steam_.cancel_text(); }
bool output_blocked() const override { return !link_.desktop() && steam_.output_blocked(); }
int shortcut_code(xkb_keysym_t symbol, int fallback) override {
// The desktop types keys by their place, like a real keyboard on it.
return link_.desktop() ? fallback : steam_.shortcut_code(symbol, fallback);
}
std::optional<bool> num_lock_state() override {
return link_.desktop() ? std::nullopt : steam_.num_lock_state();
}
private:
FrametopLink& link_;
KeySink& steam_;
std::map<int, bool> held_; // code -> pressed on the desktop
};
} // namespace framekeyboard
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include "framekeyboard/placement.hpp"
#include <optional>
namespace framekeyboard {
// Angular travel between a controller component's released and current poses.
// Translation is deliberately ignored: the render model defines the button pivot.
double component_rotation_degrees(const Transform& released, const Transform& current);
// Recover one signed stick axis from a render component's tilt. Calibration
// uses synthetic neutral/full-up poses, so model orientation and handedness do
// not need hardcoded Euler angles.
class ComponentAxis {
public:
bool calibrate(const Transform& neutral, const Transform& positive);
double read(const Transform& current) const;
private:
Transform neutral_{};
std::array<double, 3> axis_{};
double full_angle_{};
};
struct GripTransition {
bool held{}, pressed{};
};
class GripLatch {
public:
// Frame's grip component has 9.5 degrees of travel. A small dead zone and
// separate release threshold keep a resting finger from chattering capture.
// Missing samples disarm it; startup/reconnect requires a release first.
GripTransition update(std::optional<double> angle_degrees);
void reset();
private:
bool armed_{}, held_{};
};
// Recover a line capture when overlay mouse-up is lost. An initial rest pose
// can be stale, so allow a short grace period before trusting repeated rest
// samples without a preceding held sample. Missing samples break that streak.
class TriggerReleaseGuard {
public:
void begin(double now);
bool update(std::optional<double> angle_degrees, double now);
private:
double started_{};
bool seen_held_{};
std::optional<double> released_since_, sampled_at_;
};
} // namespace framekeyboard
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include "config.hpp"
#include <map>
#include <memory>
#include <optional>
#include <set>
#include <vector>
#include <xkbcommon/xkbcommon-compose.h>
#include <xkbcommon/xkbcommon.h>
namespace framekeyboard {
class OutputController;
enum class Delivery { Waiting, Success, Failure };
int key_code(const std::string& name);
bool is_modifier(int code);
// The sink receives Linux evdev codes. No renderer or profile can bypass this boundary.
class KeySink {
public:
virtual ~KeySink() = default;
virtual void send(int code, int value) = 0;
// Repeating the same operation consumes its asynchronous result, not another key.
// Releases always execute immediately, even with an outstanding down result.
virtual Delivery deliver_key(int code, int value) {
send(code, value);
return Delivery::Success;
}
virtual void cancel_delivery() { cancel_text(); }
virtual bool pump() { return true; }
// A pause/resume can happen within one pump. The app must still discard old holds.
virtual bool take_input_reset() { return false; }
virtual bool can_resume() const { return false; }
virtual bool text_available() { return false; }
virtual bool commit_text(const std::string&) { return false; }
// Optional asynchronous transports retain one commit until the caller consumes its result.
virtual bool text_pending() const { return false; }
virtual void cancel_text() {}
// An outstanding text request can still order native downs after the caller
// cancels or consumes a timeout. Releases remain allowed through this barrier.
virtual bool output_blocked() const { return false; }
virtual int shortcut_code(xkb_keysym_t, int fallback) { return fallback; }
// Unknown until the receiving system reports its lock state.
virtual std::optional<bool> num_lock_state() { return {}; }
};
class NullSink : public KeySink {
public:
void send(int, int) override {};
};
class UInputSink : public KeySink {
public:
UInputSink();
~UInputSink() override;
UInputSink(const UInputSink&) = delete;
UInputSink& operator=(const UInputSink&) = delete;
void send(int code, int value) override;
bool pump() override;
std::optional<bool> num_lock_state() override { return num_lock_; }
fs::path event_node() const;
private:
int fd_{-1};
int led_fd_{-1};
std::set<int> held_;
std::optional<bool> num_lock_;
};
class LanguageMap {
public:
explicit LanguageMap(const Language& language);
~LanguageMap();
LanguageMap(const LanguageMap&) = delete;
LanguageMap& operator=(const LanguageMap&) = delete;
std::string legend(const Key& key, const std::set<int>& modifiers, bool caps, bool num) const;
xkb_keysym_t symbol(const Key& key, const std::set<int>& modifiers, bool caps, bool num) const;
static bool printable(xkb_keysym_t symbol);
std::string compose(xkb_keysym_t symbol);
bool composing() const;
void cancel_compose();
private:
Language language_;
xkb_context* context_{};
xkb_keymap* keymap_{};
xkb_compose_state* compose_{};
};
class KeyboardState {
public:
explicit KeyboardState(KeySink& sink) : sink_(sink) {}
explicit KeyboardState(OutputController& output);
~KeyboardState();
bool down(unsigned pointer, const Key& key, double now, bool local = false, int native_code = 0);
bool up(unsigned pointer);
bool pointer_pressed(unsigned pointer) const { return presses_.contains(pointer); }
void cancel_pointer(unsigned pointer);
void cancel_all();
bool tick(double now);
bool pressed(const std::string& id) const;
std::set<int> modifiers() const;
bool caps() const { return caps_; }
bool num() const { return num_; }
// Restore the local lock state without sending a lock key to the target.
void set_num(bool enabled) { num_ = enabled; }
bool active() const { return !presses_.empty(); }
private:
struct Press {
std::string id;
int modifier{}, local_lock{};
bool used{};
std::vector<int> codes;
int repeat_code{};
double repeat_at{};
};
void acquire(int code);
void release(int code);
KeySink& sink_;
OutputController* output_{};
std::map<unsigned, Press> presses_;
std::map<int, unsigned> references_;
std::set<int> latched_;
bool caps_{false}, num_{false};
};
} // namespace framekeyboard
@@ -0,0 +1,28 @@
#pragma once
#include <filesystem>
namespace framekeyboard {
// One VR owner per login session. A second launch sends a recenter request and
// exits before initializing OpenVR or creating another virtual input device.
class VrInstance {
public:
explicit VrInstance(const std::filesystem::path& directory = runtime_directory(),
bool notify_existing = true);
~VrInstance();
VrInstance(const VrInstance&) = delete;
VrInstance& operator=(const VrInstance&) = delete;
bool is_owner() const { return owner_; }
bool poll_recenter();
static std::filesystem::path runtime_directory();
private:
void start_listener();
bool notify_owner();
std::filesystem::path socket_path_;
int lock_{-1};
int listener_{-1};
bool owner_{};
};
} // namespace framekeyboard
@@ -0,0 +1,44 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
namespace framekeyboard {
// Pure local preedit state. Nothing reaches an application until explicit commit.
class JapaneseComposer {
public:
JapaneseComposer();
~JapaneseComposer();
void roman(char key);
void kana(const std::string& text);
void backspace();
void convert();
void cycle(int direction);
void segment(int direction);
void choose(int index);
void script(bool katakana);
void cancel();
void unconvert();
std::string preedit(bool mark_active = false) const;
std::string commit_text();
bool empty() const { return reading_.empty() && pending_.empty(); }
bool converting() const { return !segments_.empty(); }
std::vector<std::string> candidates() const;
int selected() const;
int active_segment() const { return active_; }
int segment_count() const { return static_cast<int>(segments_.size()); }
private:
struct Engine;
std::unique_ptr<Engine> engine_;
struct Segment {
std::vector<std::string> candidates;
int selected{};
};
std::vector<Segment> segments_;
std::string reading_, pending_;
int active_{};
void drain(bool finish);
void append(const std::string& text);
};
} // namespace framekeyboard
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <string>
#include <vector>
namespace framekeyboard {
// Design coordinates are shared by painting and hit testing, before panel scaling.
struct Rect {
bool operator==(const Rect&) const = default;
double x{}, y{}, width{}, height{};
bool contains(double px, double py) const {
return px >= x && py >= y && px < x + width && py < y + height;
}
};
enum class Icon {
None,
ScaleDown,
ScaleUp,
Settings,
Back,
Reload,
Apply,
PinOn,
PinOff,
Recenter,
Close,
Copy,
Paste,
SteamFrame,
SteamOS
};
enum class ActionKind { Key, Shortcut };
struct Key {
bool operator==(const Key&) const = default;
std::string id, label, secondary_label;
Rect bounds;
ActionKind action_kind{};
// Physical key name or shortcut name; never a Steam numeric key code.
std::string action;
Icon icon{Icon::None};
// Modifiers normally latch on a tap. False sends a physical hold instead.
bool sticky{true};
};
struct Layout {
bool operator==(const Layout&) const = default;
std::string id, name;
double width{}, height{};
std::vector<Key> keys;
};
} // namespace framekeyboard
@@ -0,0 +1,39 @@
#pragma once
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
namespace framekeyboard {
// Unix sockets can block in connect when a compositor's accept queue is full.
// Both transports retry later instead of spending a VR frame waiting there.
inline int connect_local_socket(std::filesystem::path path) {
if (path.is_relative()) {
const char* runtime = std::getenv("XDG_RUNTIME_DIR");
if (!runtime || !*runtime) {
errno = ENOENT;
return -1;
}
path = std::filesystem::path(runtime) / path;
}
sockaddr_un address{};
address.sun_family = AF_UNIX;
const auto name = path.string();
if (name.empty() || name.size() >= sizeof(address.sun_path)) {
errno = ENAMETOOLONG;
return -1;
}
std::memcpy(address.sun_path, name.c_str(), name.size() + 1);
const int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
if (fd >= 0 && connect(fd, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) < 0) {
const int error = errno;
close(fd);
errno = error;
return -1;
}
return fd;
}
} // namespace framekeyboard
@@ -0,0 +1,171 @@
#pragma once
#include "input.hpp"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <functional>
#include <sys/types.h>
#include <utility>
namespace framekeyboard {
// Version 1: one app-scoped helper, private SOCK_SEQPACKET IPC, metadata-only status.
// Frame IPC is nonblocking; hidden idle sleep can wake on session metadata.
// CDP waits stay in the helper. Steam restores its own fields; an
// app-scoped OpenVR guard restores exact Gamescope overlays independently.
class NativeReplacement : public KeySink {
public:
// An owned IPC endpoint can be supplied by isolated receiver tests.
using Connect = std::function<int()>;
explicit NativeReplacement(KeySink& local, int transport = -1, Connect connect = {})
: local_(local), connect_(std::move(connect)), fd_(transport) {}
~NativeReplacement() override;
struct State {
std::uint64_t request{};
bool open{}, eligible{}, active{}, available{}, local{};
};
void poll(double now);
// Between hidden frames, wake the idle sleep when session metadata changes.
void wait_until(std::chrono::steady_clock::time_point deadline);
void update(bool enabled, bool ready, std::uint64_t acknowledgement, bool fallback, double now,
bool watch_main_loop = false);
const State& state() const { return state_; }
const char* unavailable_reason() const;
void route(bool remote) { remote_ = remote; }
bool remote() const { return remote_; }
void dismiss_request();
// A prepared update closes the current request instead of reopening native.
// All other shutdowns retain independent native recovery.
void shutdown_for_update();
void retry_now(double now);
bool take_unsupported_control();
// Prepare before the VR watchdog starts; descriptors remain stable until shutdown.
bool prepare_watchdog();
void watchdog_tick(double now, double slow_work_deadline = 0) const;
// Main-thread scopes identify external runtime waits. Nested calls share a
// fixed frame deadline; returning grants only a short completion margin.
void begin_runtime_wait(double now);
void end_runtime_wait(double now);
void frame_completed();
double runtime_wait_deadline() const { return runtime_until_.load(); }
void send(int code, int value) override;
Delivery deliver_key(int code, int value) override;
void cancel_delivery() override;
bool pump() override { return local_.pump(); }
bool take_input_reset() override;
bool can_resume() const override { return local_.can_resume(); }
bool text_available() override { return local_.text_available(); }
bool commit_text(const std::string& text) override;
bool text_pending() const override { return pending_id_ != 0 || local_.text_pending(); }
void cancel_text() override;
bool output_blocked() const override { return !remote_ && local_.output_blocked(); }
int shortcut_code(xkb_keysym_t symbol, int fallback) override {
return local_.shortcut_code(symbol, fallback);
}
std::optional<bool> num_lock_state() override { return local_.num_lock_state(); }
private:
enum class Failure {
None,
DebuggerUnavailable,
DebuggerBlocked,
SteamIncompatible,
InstallTimeout,
ReplyTimeout,
ReplacementBusy,
HelperUnavailable,
ConnectionLost
};
bool packet(const std::string& data);
bool start(double now);
const char* failure_code() const;
void diagnostic(const char* code, double now, std::uint64_t request = 0, int retry = 0);
void schedule_retry(double now);
void stop(bool restore_native = true);
KeySink& local_;
Connect connect_;
int fd_{-1};
pid_t child_{-1};
int watchdog_read_{-1}, watchdog_write_{-1};
std::atomic<double> runtime_until_{};
double runtime_frame_deadline_{};
unsigned runtime_depth_{};
State state_;
Failure failure_{Failure::None};
bool remote_{}, reset_{}, force_fallback_{}, unsupported_control_{};
double last_reply_{}, install_until_{}, next_start_{}, now_{}, pending_since_{};
double retry_delay_{5};
double log_window_{-1}, last_failure_log_{-60}, logged_retry_{};
unsigned log_lines_{};
Failure logged_failure_{Failure::None};
std::uint64_t event_sequence_{};
bool enabled_{};
std::uint64_t progress_{}, sequence_{}, pending_id_{};
std::string pending_text_;
std::optional<bool> pending_result_;
std::uint64_t native_id_{};
int native_code_{}, native_value_{};
double native_since_{};
std::optional<bool> native_result_;
struct RemoteKey {
std::uint64_t id, request;
bool pending;
};
std::map<int, RemoteKey> remote_keys_;
std::set<int> modifiers_, local_keys_;
};
// Keep panel dismissal separate from process lifetime. All visibility decisions use the
// same request snapshot, so closing X cannot acknowledge or reopen the old request.
class ReplacementPresentation {
public:
explicit ReplacementPresentation(bool start_hidden = false) : startup_hidden_(start_hidden) {}
void update(bool enabled, NativeReplacement::State state) {
if (enabled != enabled_) {
managed_ = dismissed_ = false;
bypass_request_ = dismissed_request_ = 0;
}
enabled_ = enabled;
state_ = state;
if (!state.available) {
bypass_request_ = dismissed_request_ = 0;
}
if (enabled && state.available && state.open) {
managed_ = true;
startup_hidden_ = false;
}
}
void dismiss() {
managed_ = dismissed_ = true;
dismissed_request_ = state_.request;
}
void summon() {
startup_hidden_ = false;
dismissed_ = false;
if (!state_.open) {
managed_ = false;
}
}
void fallback() {
bypass_request_ = state_.request;
managed_ = true;
}
bool show() const {
return !startup_hidden_ &&
(!enabled_ || (!managed_ && !dismissed_) ||
(state_.available && state_.open && state_.eligible &&
state_.request != bypass_request_ && state_.request != dismissed_request_));
}
bool interactive() const {
return !startup_hidden_ &&
(!enabled_ || (!managed_ && !dismissed_) || (show() && state_.active));
}
bool remote() const { return enabled_ && managed_ && show() && state_.active && !state_.local; }
bool fallback_requested() const { return state_.open && state_.request == bypass_request_; }
private:
NativeReplacement::State state_;
bool enabled_{}, managed_{}, dismissed_{};
bool startup_hidden_{};
std::uint64_t bypass_request_{}, dismissed_request_{};
};
} // namespace framekeyboard
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include "input.hpp"
#include <deque>
namespace framekeyboard {
// One field-scoped output session. Pointer/legend state stays in KeyboardState;
// transport protocol state stays in the sinks. Only this class orders delivery.
class OutputController : public KeySink {
public:
explicit OutputController(KeySink& target) : target_(target) {}
~OutputController() override;
static constexpr std::size_t capacity = 32;
bool admit(bool produces_output, double now);
bool admit(bool produces_output) { return admit(produces_output, now_); }
void native_press(unsigned pointer, const std::vector<int>& codes, int repeat_code, double now);
bool text_press(unsigned pointer, const std::string& text, double now);
void release(unsigned pointer);
void release_modifier(int code);
void cancel();
bool tick(double now);
bool retry();
bool retry_available() const { return !retry_.empty(); }
std::string retry_text() const;
std::optional<std::string> take_status();
void set_enabled(bool value);
bool enabled() const { return enabled_; }
// Lock synchronization uses the same ordered path as ordinary presses.
void send(int code, int value) override;
bool pump() override { return target_.pump(); }
bool take_input_reset() override { return target_.take_input_reset(); }
bool can_resume() const override { return target_.can_resume(); }
bool text_available() override { return target_.text_available(); }
bool commit_text(const std::string& text) override;
bool text_pending() const override { return !queue_.empty(); }
void cancel_text() override { cancel(); }
int shortcut_code(xkb_keysym_t symbol, int fallback) override {
return target_.shortcut_code(symbol, fallback);
}
std::optional<bool> num_lock_state() override { return target_.num_lock_state(); }
private:
struct Entry {
std::string text;
std::vector<int> codes, acquired;
std::set<int> released_modifiers;
std::size_t next{};
int repeat_code{};
bool held{}, started{}, waiting{}, repeat{};
double repeat_at{};
std::weak_ptr<Entry> owner;
};
bool enqueue_text(const std::string& text, std::shared_ptr<Entry> entry = {});
void drain();
void fail();
void drop_holds();
void release_codes(Entry& entry, std::optional<int> only = {});
void status(std::string message);
KeySink& target_;
std::deque<std::shared_ptr<Entry>> queue_, retry_;
std::map<unsigned, std::shared_ptr<Entry>> active_;
std::map<int, std::shared_ptr<Entry>> direct_;
std::map<int, unsigned> delivered_;
double now_{};
bool enabled_{}, failed_{};
std::string status_;
bool status_changed_{};
};
} // namespace framekeyboard
+150
View File
@@ -0,0 +1,150 @@
#pragma once
#include "input.hpp"
#include <cairo.h>
#include <optional>
namespace framekeyboard {
constexpr int panel_width = 1600;
constexpr int panel_height = 600;
// Transparent space above/below the case lets dropdowns escape its border.
// Logical UI and placement coordinates still describe the 1600 x 600 case.
constexpr int popup_margin = 200;
constexpr int texture_height = panel_height + 2 * popup_margin;
enum class ControlStyle { Button, Dropdown, Checkbox, MenuItem, Scrollbar, MoveHandle };
enum class ControlKind {
None,
Settings,
Apply,
Reload,
Updates,
NativeFallback,
Quit,
Recenter,
Smaller,
Larger,
Pin,
LanguageCycle,
Close,
MoveHandle,
ToggleDropdown,
ChooseLayout,
ChooseLanguage,
ChooseTheme,
Favorite,
HideNumpad,
UseDragToMove,
AutoLeveling,
ReplaceNativeKeyboard,
StartWithSteamos,
ScrollMenu,
// Contiguous composition groups support policy checks without inspecting IDs.
ImeToggle,
ImeConvert,
ImeCommit,
ImeCancel,
ImeHiragana,
ImeKatakana,
ImeNext,
ImePrev,
ImeSegmentNext,
ImeSegmentPrev,
ImeCandidate,
CjkToggle,
CjkCommit,
CjkCancel,
CjkNext,
CjkPrev,
CjkCandidate
};
struct ControlAction {
ControlKind kind{ControlKind::None};
int index{};
std::string value{};
bool operator==(const ControlAction&) const = default;
};
struct Control {
bool operator==(const Control&) const = default;
std::string id, label;
Rect bounds;
bool selected{};
Icon icon{Icon::None};
ControlStyle style{ControlStyle::Button};
std::optional<Rect> clip{};
bool enabled{true};
ControlAction action{};
bool hit(double x, double y) const {
return enabled && bounds.contains(x, y) && (!clip || clip->contains(x, y));
}
};
struct CardView {
std::string title;
Rect bounds;
};
struct FieldLabel {
std::string text;
Rect bounds, clip;
bool left{};
bool enabled{true};
};
struct PanelView {
const Layout* layout{};
const Theme* theme{};
const Language* language{};
const LanguageMap* keymap{};
const KeyboardState* keyboard{};
std::vector<Control> controls;
std::vector<CardView> cards;
std::vector<FieldLabel> field_labels;
std::optional<Rect> popup;
std::string status;
bool settings{};
bool composing{};
bool hide_numpad{};
std::string preedit;
std::map<std::string, std::string> key_labels;
std::set<std::string> hovered_keys, hovered_controls;
};
bool hidden_key(const PanelView& view, const Key& key);
// Logical case bounds also define the VR interaction area. Hidden numpad space
// stays transparent in the fixed-size texture, preserving the dashboard anchor.
Rect panel_case_bounds(const PanelView& view);
// This is the complete hit region, including transparent padding around the line.
std::optional<Rect> panel_move_handle_bounds(const PanelView& view);
class PanelRenderer {
public:
PanelRenderer();
~PanelRenderer();
PanelRenderer(const PanelRenderer&) = delete;
PanelRenderer& operator=(const PanelRenderer&) = delete;
// force_full is used by pixel-equivalence tests and rendering benchmarks.
void paint(const PanelView& view, double now, bool force_full = false);
const Key* hit_key(const PanelView& view, double x, double y) const;
bool animating() const { return animating_; }
cairo_surface_t* surface() const { return surface_; }
std::vector<unsigned char> rgba() const;
void write_png(const fs::path& path) const;
private:
struct Animation {
double amount{}, target{}, from{}, started{};
};
struct Snapshot {
Layout layout;
Theme theme;
Language language;
// Own all compared values; never dereference pointers from an old view.
PanelView view;
std::set<int> modifiers;
bool caps{}, num{};
};
std::optional<Snapshot> previous_;
std::map<std::string, Rect> key_bounds_;
std::map<std::string, Animation> animations_;
Animation move_handle_;
cairo_surface_t* surface_{};
bool animating_{};
};
} // namespace framekeyboard
@@ -0,0 +1,100 @@
#pragma once
#include <array>
#include <optional>
namespace framekeyboard {
// Row-major rigid transform. Its columns are right, up, toward viewer, position.
using Transform = std::array<std::array<double, 4>, 3>;
// Normalize Steam's scaled overlay pose into a rigid anchor, retaining its full rotation.
std::optional<Transform> dashboard_anchor(const Transform& scaled_bottom);
Transform move_with_dashboard(const Transform& panel, const Transform& previous,
const Transform& current);
// Steam's main tab freezes when an app tab is selected. Carry its last live
// bottom-center anchor with the persistent dashboard bar during those tabs.
class DashboardAnchor {
public:
void restore(std::optional<Transform> anchor, std::optional<Transform> bar,
bool full_rotation = true);
std::optional<Transform> update(std::optional<Transform> main, std::optional<Transform> bar);
const std::optional<Transform>& bar() const { return bar_; }
private:
std::optional<Transform> anchor_, bar_;
bool full_rotation_{true};
};
// Roll-only horizon assistance. Feed the uncorrected pose each frame so the
// animation never feeds its own correction back into the controller grab.
class HorizonAlignment {
public:
Transform update(const Transform& raw, double now, bool grabbed = false, bool enabled = true);
void reset() { *this = {}; }
bool animating() const { return animating_; }
private:
double started_{}, from_correction_{}, correction_{};
bool within_threshold_{}, animating_{};
};
enum class PlacementAction { Smaller, Larger };
enum class MoveSource { Laser, Grip };
// Capture belongs to the initiating head/controller and cursor. Another pointer
// must not steal or release movement, even when it reuses the same cursor index.
class MoveCapture {
public:
bool begin(MoveSource source, unsigned device, unsigned pointer = 0) {
if (active()) {
return false;
}
source_ = source;
device_ = device;
pointer_ = pointer;
return true;
}
bool active() const { return source_.has_value(); }
std::optional<MoveSource> source() const { return source_; }
unsigned device() const { return device_; }
bool owns_pointer(unsigned device, unsigned pointer) const {
return source_ == MoveSource::Laser && device_ == device && pointer_ == pointer;
}
void reset() { source_.reset(); }
private:
std::optional<MoveSource> source_;
unsigned device_{}, pointer_{};
};
class PanelPlacement {
public:
void recenter(const Transform& head, const Transform* dashboard_bottom = nullptr,
double height_over_width = 600.0 / 1600.0);
// Where recenter puts the panel without a dashboard: its centre `ahead` along the
// level heading and `below` under the eyes, its top edge tilted `tilt_degrees`
// away from upright. Frametop's mode puts it where its own keyboard opened.
void set_head_spot(double ahead, double below, double tilt_degrees);
void adjust(PlacementAction action);
Transform transform() const { return world_; }
void set_transform(const Transform& world);
void set_width(double width);
void restore(const Transform& world, double width);
double width() const { return width_; }
bool ready() const { return ready_; }
private:
Transform world_{};
double width_{.95};
bool ready_{};
double ahead_{.85}, below_{.65}, tilt_degrees_{50};
};
// Store the initial controller-to-panel transform, so grabbing never snaps the
// panel center to the ray. Later updates preserve the exact grabbed point.
class PanelDrag {
public:
void begin(const Transform& controller, const Transform& panel,
std::array<double, 3> ray_direction = {0, 0, -1});
void move_depth(double stick_y, double elapsed_seconds);
Transform update(const Transform& controller) const;
private:
Transform relative_{};
std::array<double, 3> ray_direction_{};
double depth_{}, minimum_depth_{}, maximum_depth_{};
};
} // namespace framekeyboard
@@ -0,0 +1,18 @@
#pragma once
#include "placement.hpp"
#include <cstdint>
#include <filesystem>
#include <optional>
namespace framekeyboard {
struct SavedPlacement {
Transform transform;
double width;
std::uint64_t universe;
std::optional<Transform> dashboard{};
std::optional<Transform> dashboard_bar{};
bool dashboard_full_rotation{};
};
std::optional<SavedPlacement> load_placement(const std::filesystem::path& path);
void save_placement(const std::filesystem::path& path, const SavedPlacement& placement);
} // namespace framekeyboard
@@ -0,0 +1,73 @@
#pragma once
#include "panel.hpp"
namespace framekeyboard {
struct SettingChoice {
std::string id, name;
};
struct SettingField {
std::string id, label, value;
ControlStyle style{ControlStyle::Dropdown};
bool checked{};
std::vector<SettingChoice> choices{};
bool label_after{};
// An inline label has a small row inset and precedes its checkbox.
double inline_label_width{};
bool enabled{true};
// Dropdowns use this kind for choosing a value; their row opens generically.
ControlAction action{};
};
struct SettingsCard {
std::string id, title;
std::vector<SettingField> fields;
};
// Generic card/scroll state. App supplies the real fields; tests can supply extra
// cards and rows without adding fake preferences to production configuration.
class SettingsUi {
public:
static constexpr Rect viewport{18, 78, 1564, 476};
void set_cards(std::vector<SettingsCard> cards);
void append(PanelView& view) const;
void reset();
void close_popup();
bool popup_open() const { return !open_field_.empty(); }
void toggle(const std::string& field);
bool scroll(double x, double y, double dx, double dy);
bool down(unsigned pointer, double x, double y);
bool move(unsigned pointer, double x, double y);
bool up(unsigned pointer);
bool pressed(unsigned pointer) const { return capture_ && capture_->pointer == pointer; }
void cancel_pointer(unsigned pointer);
void cancel();
private:
Rect card_bounds(std::size_t index) const;
Rect body(std::size_t index) const;
Rect field_bounds(std::size_t card, std::size_t field) const;
double vertical_limit(std::size_t card) const;
double horizontal_limit() const;
const SettingField* open_field() const;
std::optional<Rect> popup_bounds() const;
struct Scrollbar {
std::string id;
Rect track, thumb;
double limit{}, offset{};
std::size_t card{};
bool horizontal{};
};
std::vector<Scrollbar> scrollbars() const;
std::vector<SettingsCard> cards_;
std::map<std::string, double> vertical_;
double horizontal_{};
std::string open_field_;
int first_choice_{};
double choice_remainder_{};
struct Capture {
unsigned pointer;
Scrollbar bar;
double start{}, offset{};
};
std::optional<Capture> capture_;
};
} // namespace framekeyboard
@@ -0,0 +1,50 @@
#pragma once
#include "config.hpp"
#include <condition_variable>
#include <functional>
#include <mutex>
#include <optional>
#include <thread>
namespace framekeyboard {
// Owns only preference snapshots, never App/input state. One worker serializes
// atomic writes; pending snapshots coalesce so an older write cannot win later.
class SettingsWriter {
public:
using Write = std::function<void(const Settings&)>;
struct Result {
std::string error;
std::string startup_warning;
bool reconcile{};
};
explicit SettingsWriter(fs::path directory, Write write = {}, Write reconcile = {});
~SettingsWriter();
SettingsWriter(const SettingsWriter&) = delete;
SettingsWriter& operator=(const SettingsWriter&) = delete;
void enqueue(Settings settings);
// Startup/Reload reconcile saved settings without writing JSON or changing profiles.
void reconcile(Settings settings);
std::optional<Result> take_result();
// Queueing belongs to App's thread. It can defer Reload until all older
// snapshots finish without waiting for the worker's I/O or startup work.
bool idle();
// Explicit durability barrier for shutdown and tests, never a UI action.
void flush();
private:
void run();
struct Request {
Settings settings;
bool reconcile{};
};
void queue(Request request);
Write write_, reconcile_;
std::mutex mutex_;
std::condition_variable changed_;
std::optional<Request> pending_;
std::optional<Result> result_;
bool writing_{}, stopping_{};
std::thread worker_;
};
} // namespace framekeyboard
@@ -0,0 +1,25 @@
#pragma once
#include "config.hpp"
#include <functional>
#include <stdexcept>
namespace framekeyboard {
// Boot launches from older units lack FRAMEKEYBOARD_SERVICE. Both those and
// marked update successors need the service's no-restart status, unlike manual VR.
int deliberate_exit_status(bool autostart);
// Raised only after unrelated preferences were saved successfully. The two
// startup-related flags retain their prior saved values when unit setup fails.
class StartupPreferencesWarning : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
void save_preferences_with_startup(const Settings& settings, const fs::path& executable,
const fs::path& config_dir, fs::path unit_directory = {},
std::function<void()> reload = {});
// Runs on the preference writer, never the VR loop. Tests supply a private unit
// directory and reload callback; previews never configure session startup.
// Persist runs after configuration succeeds; its failure restores the old unit.
void configure_startup(const Settings& settings, const fs::path& executable, const fs::path& config_dir,
fs::path unit_directory = {}, std::function<void()> reload = {},
std::function<void()> persist = {});
} // namespace framekeyboard
@@ -0,0 +1,26 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
namespace framekeyboard {
// Optional Gamescope UTF-8 transport. No clipboard or application text is read.
// Discovery and commits progress only through nonblocking dispatch in ready().
class GamescopeText {
public:
using Clock = std::function<double()>;
explicit GamescopeText(const std::string& socket, Clock clock = {});
~GamescopeText();
bool ready();
bool failed() const;
bool commit(const std::string& text);
bool pending() const;
// Cancellation/timeout cannot undo an already submitted Wayland request.
// Keep its ordering barrier until the sync arrives, even if the caller stops waiting.
bool busy() const;
void cancel();
private:
struct State;
std::unique_ptr<State> state_;
};
} // namespace framekeyboard
@@ -0,0 +1,53 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace framekeyboard {
// Boot opt-in is checked once. User-requested update restarts are normal launches.
std::vector<std::string> update_restart_arguments(const std::vector<std::string>& arguments);
// The UI only polls private packets. Network and installation work runs in a child.
class Updates {
public:
Updates(std::string version, std::vector<std::string> arguments, std::filesystem::path helper = {});
~Updates();
Updates(const Updates&) = delete;
Updates& operator=(const Updates&) = delete;
std::string label() const;
std::string failure_reason() const;
bool busy() const;
void click(double now);
bool tick(double now);
bool prepared() const { return state_ == State::Prepared; }
// Call after App, input, VR, replacement and the instance lock are gone.
bool handoff();
private:
enum class State { Idle, Checking, Latest, Available, Installing, Error, Prepared };
bool start();
bool send(const std::string& operation);
void stop();
void fail(double now, const std::string& reason = {});
std::string version_, offer_, progress_, failure_;
std::vector<std::string> arguments_;
std::filesystem::path helper_;
State state_{State::Idle};
double reset_at_{};
int fd_{-1}, child_{-1};
bool handed_off_{};
};
// A private socket acknowledges three seconds of completed-loop progress after an update.
class UpdateStartup {
public:
UpdateStartup();
~UpdateStartup();
// Update/rollback is explicit; hidden presentation does not recheck boot opt-in.
bool background_start() const { return background_; }
private:
bool background_{};
};
void update_startup_ready(double now);
} // namespace framekeyboard
@@ -0,0 +1,62 @@
#pragma once
#include "feedback.hpp"
#include "openvr.h"
#include <array>
#include <filesystem>
#include <iostream>
namespace framekeyboard {
class VrHaptics {
public:
bool connect(const std::filesystem::path& manifest) {
auto* input = vr::VRInput();
if (!input || !std::filesystem::is_regular_file(manifest)) {
std::cerr << "Key haptics unavailable: missing interface or action manifest.\n";
return false;
}
ready_ = check(input->SetActionManifestPath(std::filesystem::absolute(manifest).c_str())) &&
check(input->GetActionSetHandle("/actions/feedback", &set_)) &&
check(input->GetActionHandle("/actions/feedback/out/key", &action_)) &&
check(input->GetInputSourceHandle("/user/hand/left", &hands_[0])) &&
check(input->GetInputSourceHandle("/user/hand/right", &hands_[1]));
return ready_;
}
bool send(unsigned device, KeyFeedback kind) {
if (!ready_ || !vr::VRSystem()->IsTrackedDeviceConnected(device) ||
vr::VRSystem()->GetTrackedDeviceClass(device) != vr::TrackedDeviceClass_Controller) {
return false;
}
const auto role = vr::VRSystem()->GetControllerRoleForTrackedDeviceIndex(device);
if (role != vr::TrackedControllerRole_LeftHand && role != vr::TrackedControllerRole_RightHand) {
return false;
}
// This set binds only outputs. It neither claims trigger/grip inputs nor
// uses the overlay-wide API, whose laser selection ignores our pointer.
vr::VRActiveActionSet_t active{};
active.ulActionSet = set_;
if (!check(vr::VRInput()->UpdateActionState(&active, sizeof(active), 1))) {
return false;
}
const auto hand = hands_[role == vr::TrackedControllerRole_LeftHand ? 0 : 1];
const auto pulse = key_pulse(kind);
return check(vr::VRInput()->TriggerHapticVibrationAction(
action_, 0, pulse.duration, pulse.frequency, pulse.amplitude, hand));
}
private:
bool check(vr::EVRInputError error) {
if (error == vr::VRInputError_None) {
return true;
}
if (!reported_) {
std::cerr << "Key haptics unavailable: OpenVR input error " << error << '\n';
reported_ = true;
}
return false;
}
vr::VRActionSetHandle_t set_{};
vr::VRActionHandle_t action_{};
std::array<vr::VRInputValueHandle_t, 2> hands_{};
bool ready_{}, reported_{};
};
} // namespace framekeyboard
@@ -0,0 +1,59 @@
#pragma once
#include "native_replacement.hpp"
#include "vr_runtime.hpp"
#include <functional>
#include <optional>
namespace framekeyboard {
// Runtime effects stay in the OpenVR shell. The coordinator owns their ordering;
// host receivers can exercise the same transitions without a headset or OS input.
struct VrLifecycleEffects {
std::function<void()> stop_drag, cancel_input, clear_pointers, hide_overlay;
std::function<void(bool)> route_input, set_interaction;
std::function<void(double)> show_overlay, retry_bridge;
std::function<void()> summon, dismiss_request;
std::function<void(std::uint32_t)> cancel_device;
};
class VrLifecycle {
public:
VrLifecycle(bool background_start, VrLifecycleEffects effects);
struct Frame {
bool replacement_enabled{};
NativeReplacement::State native;
bool routed_remote{}, watchdog_stalled{}, relaunch{};
double now{};
};
void begin_frame(const Frame& frame);
void dashboard_visibility(bool visible);
void hide();
void dismiss();
void fallback();
void focus_lost();
void device_lost(std::uint32_t device, std::optional<std::uint32_t> drag_owner);
void pointer_left(std::uint32_t device, bool dragging);
void request_recenter();
void request_tracking_sample() { tracking_.request_sample(); }
bool should_sample_tracking(bool dashboard_visible, double now) const;
void tracking_sampled(bool valid, double now);
bool waiting_for_tracking() const { return tracking_.waiting(); }
bool panel_requested() const { return presentation_.show(); }
bool visible() const { return visible_; }
// Call after placement work; only successful runtime presentation consumes a pending show.
void present(bool placement_ready, bool dashboard_visible, bool compositor_visible, double now);
bool ready(bool backend_ready, bool dashboard_visible) const;
struct FrameReport {
bool ready{}, fallback{};
std::uint64_t acknowledgement{};
};
FrameReport frame_report(bool backend_ready, bool dashboard_visible, bool compositor_visible) const;
private:
VrLifecycleEffects effects_;
ReplacementPresentation presentation_;
TrackingReadiness tracking_;
NativeReplacement::State native_;
std::uint64_t last_request_{};
bool visible_{}, show_pending_{};
};
} // namespace framekeyboard
@@ -0,0 +1,21 @@
#pragma once
#include <filesystem>
namespace framekeyboard {
bool steamvr_running(const std::filesystem::path& proc = "/proc");
void require_steamvr();
// Tracking recovery must remain observable after a replacement lease hides the panel.
class TrackingReadiness {
public:
bool should_sample(bool dashboard_visible, bool panel_requested, bool panel_visible,
double now) const;
void sampled(bool valid, double now);
void request_sample() { next_check_ = 0; }
bool waiting() const { return waiting_; }
private:
bool waiting_{};
double next_check_{};
};
} // namespace framekeyboard
@@ -0,0 +1,87 @@
#pragma once
#include "openvr.h"
#include "panel.hpp"
#include "vk_texture.h"
#include <bit>
namespace framekeyboard {
// Full images still alternate between the two Vulkan textures. Damage tracking
// belongs to the renderer; partial uploads would need damage history per texture.
class PanelTexture {
public:
bool create(VulkanContext& context, vr::VROverlayHandle_t overlay, std::string& error,
bool prefer_native = true) {
context_ = &context;
overlay_ = overlay;
native_ = false;
VkImageFormatProperties properties{};
const bool supported = std::endian::native == std::endian::little &&
vkGetPhysicalDeviceImageFormatProperties(
context.physicalDevice(), VK_FORMAT_B8G8R8A8_UNORM, VK_IMAGE_TYPE_2D,
VK_IMAGE_TILING_OPTIMAL,
VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT |
VK_IMAGE_USAGE_SAMPLED_BIT,
0, &properties) == VK_SUCCESS;
if (prefer_native && supported &&
vr::VROverlay()->SetOverlayFlag(overlay_, vr::VROverlayFlags_IsPremultiplied, true) ==
vr::VROverlayError_None) {
premultiplied_enabled_ = true;
if (native_texture_.create(context, panel_width, texture_height, error,
VK_FORMAT_B8G8R8A8_UNORM)) {
native_ = true;
return true;
}
}
return create_rgba(error);
}
bool native_pixels() const { return native_; }
void destroy() {
native_texture_.destroy();
rgba_texture_.destroy();
}
bool update(const PanelRenderer& renderer, std::string& error) {
if (native_) {
// Cairo ARGB32 is premultiplied BGRA bytes on little-endian Frame.
// Its fixed-size image surface is tightly packed; check rather than
// silently assuming that a future renderer keeps the same stride.
auto* surface = renderer.surface();
if (cairo_image_surface_get_stride(surface) == panel_width * 4 &&
native_texture_.update(overlay_, cairo_image_surface_get_data(surface), error)) {
return true;
}
// A runtime may accept the flag/format but reject the submitted
// texture. Switch safely while the old images are still alive.
const auto cleared = vr::VROverlay()->ClearOverlayTexture(overlay_);
if (cleared != vr::VROverlayError_None) {
error = "Cannot clear rejected native overlay texture";
return false;
}
if (!create_rgba(error)) {
return false;
}
}
const auto rgba = renderer.rgba();
return rgba_texture_.update(overlay_, rgba.data(), error);
}
private:
bool create_rgba(std::string& error) {
native_ = false;
if (premultiplied_enabled_ &&
vr::VROverlay()->SetOverlayFlag(overlay_, vr::VROverlayFlags_IsPremultiplied, false) !=
vr::VROverlayError_None) {
error = "Cannot configure straight-alpha overlay fallback";
return false;
}
premultiplied_enabled_ = false;
error.clear();
return rgba_texture_.create(*context_, panel_width, texture_height, error);
}
VulkanContext* context_{};
vr::VROverlayHandle_t overlay_{};
// Retain rejected native images until VR shutdown, just like accepted ones.
OverlayTexture native_texture_, rgba_texture_;
bool native_{}, premultiplied_enabled_{};
};
} // namespace framekeyboard
+24
View File
@@ -0,0 +1,24 @@
{
"schema_version": 1,
"id": "de-de",
"name": "Deutsch",
"locale": "de-DE",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "de",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
],
"required_keys": [
"IntlBackslash"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"schema_version": 1,
"id": "en-gb",
"name": "English (UK)",
"locale": "en-GB",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "gb",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
],
"required_keys": [
"IntlBackslash"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"schema_version": 1,
"id": "en-us",
"name": "English (US)",
"locale": "en-US",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "us",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"schema_version": 1,
"id": "es-es",
"name": "Español — España",
"locale": "es-ES",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "es",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
],
"required_keys": [
"IntlBackslash"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"schema_version": 1,
"id": "fr-fr",
"name": "Français — AZERTY",
"locale": "fr-FR",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "fr",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
],
"required_keys": [
"IntlBackslash"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"schema_version": 1,
"id": "it-it",
"name": "Italiano",
"locale": "it-IT",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "it",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
],
"required_keys": [
"IntlBackslash"
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"schema_version": 1,
"id": "ja-jis",
"name": "日本語 — JIS / system IME",
"locale": "ja-JP",
"keymap": {
"rules": "evdev",
"model": "jp106",
"layout": "jp",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans CJK JP",
"Noto Sans",
"sans-serif"
],
"required_keys": [
"IntlYen",
"IntlRo",
"Convert",
"NonConvert",
"KanaMode",
"ZenkakuHankaku"
]
}
+98
View File
@@ -0,0 +1,98 @@
{
"schema_version": 1,
"id": "ja-kana",
"name": "日本語 — Kana",
"locale": "ja-JP",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "us",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans CJK JP",
"Noto Sans",
"sans-serif"
],
"input_method": "japanese-kana",
"required_keys": [
"IntlYen",
"IntlRo",
"Convert",
"NonConvert",
"KanaMode",
"ZenkakuHankaku"
],
"kana": {
"Digit1": "ぬ",
"Digit2": "ふ",
"Digit3": "あ",
"Digit4": "う",
"Digit5": "え",
"Digit6": "お",
"Digit7": "や",
"Digit8": "ゆ",
"Digit9": "よ",
"Digit0": "わ",
"Minus": "ほ",
"Equal": "へ",
"IntlYen": "ー",
"KeyQ": "た",
"KeyW": "て",
"KeyE": "い",
"KeyR": "す",
"KeyT": "か",
"KeyY": "ん",
"KeyU": "な",
"KeyI": "に",
"KeyO": "ら",
"KeyP": "せ",
"BracketLeft": "゛",
"BracketRight": "゜",
"KeyA": "ち",
"KeyS": "と",
"KeyD": "し",
"KeyF": "は",
"KeyG": "き",
"KeyH": "く",
"KeyJ": "ま",
"KeyK": "の",
"KeyL": "り",
"Semicolon": "れ",
"Quote": "け",
"Backslash": "む",
"KeyZ": "つ",
"KeyX": "さ",
"KeyC": "そ",
"KeyV": "ひ",
"KeyB": "こ",
"KeyN": "み",
"KeyM": "も",
"Comma": "ね",
"Period": "る",
"Slash": "め",
"IntlRo": "ろ"
},
"kana_shift": {
"Digit3": "ぁ",
"Digit4": "ぅ",
"Digit5": "ぇ",
"Digit6": "ぉ",
"Digit7": "ゃ",
"Digit8": "ゅ",
"Digit9": "ょ",
"Digit0": "を",
"KeyE": "ぃ",
"KeyZ": "っ",
"BracketRight": "「",
"Backslash": "」",
"Comma": "、",
"Period": "。",
"Slash": "・"
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"schema_version": 1,
"id": "ja-romaji",
"name": "日本語 — Romaji",
"locale": "ja-JP",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "us",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans CJK JP",
"Noto Sans",
"sans-serif"
],
"input_method": "japanese-romaji"
}
+60
View File
@@ -0,0 +1,60 @@
{
"schema_version": 1,
"id": "ko-kr",
"name": "한국어 — 두벌식",
"locale": "ko-KR",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "us",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans CJK KR",
"Noto Sans",
"sans-serif"
],
"input_method": "korean-2set",
"composition_keys": {
"KeyQ": "ㅂ",
"KeyW": "ㅈ",
"KeyE": "ㄷ",
"KeyR": "ㄱ",
"KeyT": "ㅅ",
"KeyY": "ㅛ",
"KeyU": "ㅕ",
"KeyI": "ㅑ",
"KeyO": "ㅐ",
"KeyP": "ㅔ",
"KeyA": "ㅁ",
"KeyS": "ㄴ",
"KeyD": "ㅇ",
"KeyF": "ㄹ",
"KeyG": "ㅎ",
"KeyH": "ㅗ",
"KeyJ": "ㅓ",
"KeyK": "ㅏ",
"KeyL": "ㅣ",
"KeyZ": "ㅋ",
"KeyX": "ㅌ",
"KeyC": "ㅊ",
"KeyV": "ㅍ",
"KeyB": "ㅠ",
"KeyN": "ㅜ",
"KeyM": "ㅡ"
},
"composition_shift": {
"KeyQ": "ㅃ",
"KeyW": "ㅉ",
"KeyE": "ㄸ",
"KeyR": "ㄲ",
"KeyT": "ㅆ",
"KeyO": "ㅒ",
"KeyP": "ㅖ"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"schema_version": 1,
"id": "pt-br",
"name": "Português — Brasil ABNT2",
"locale": "pt-BR",
"keymap": {
"rules": "evdev",
"model": "abnt2",
"layout": "br",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
],
"required_keys": [
"IntlBackslash",
"IntlRo",
"NumpadComma"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"schema_version": 1,
"id": "ru-ru",
"name": "Русский",
"locale": "ru-RU",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "ru",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"schema_version": 1,
"id": "uk-ua",
"name": "Українська",
"locale": "uk-UA",
"keymap": {
"rules": "evdev",
"model": "pc105",
"layout": "ua",
"variant": "",
"options": []
},
"legends": {
"source": "keymap",
"overrides": {}
},
"font_families": [
"Noto Sans",
"sans-serif"
]
}
Loaded 100 of 295 files, more files were not shown because too many files have changed in this diff. Show more