remap_volume stopped at the first EVIOCSKEYCODE_V2 that failed, so the
volume entries after it were never tried. On a keyboard where swapping
volume up failed, volume down stayed a real key that gamescope reads,
and one press with nothing focused aborts gamescope and the VR session.
Restoring had the same hole: a failed entry left the later stand-ins in
place after the relay exited.
Now every entry is tried and the first failure is raised at the end.
take_volume already marks the device remapped on that error, so the
relay routes the stand-ins that did swap and restores them on exit.
Checked against a fake keymap with a stubbed fcntl.ioctl: with volume
up's swap failing, volume down now swaps (it stayed real before), the
same for restore, and full swaps, restores and the no-keymap case come
out as before. Found by 0x1f6 in PR #8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One bad datagram on the control socket ended the whole relay. Its
dispatch in handle_control ran unguarded in the main loop, so any
exception propagated out of main() and the process exited. The simplest
trigger is "watch abc": float(words[1]) raises ValueError, and the relay
died with
ValueError: could not convert string to float: 'abc'
at handle_control, via the bare handle_control(now) call in the
select loop
The control socket is an abstract socket bound to @frametop_relay.
Abstract sockets carry no permissions, so every local process can send
to it; nothing authenticates the sender. Dying from a datagram was bad
in three ways:
- Every grab is lost. Physical devices go back to gamescope and SteamVR,
which read them themselves, so the mouse types into Steam and the
desktop at once, and Meta+Shift shortcuts fire on both sides.
- The volume-key takeover is lost. gamescope aborts on a volume key when
no window has keyboard focus (wlr_seat_keyboard_notify_enter asserts on
a null focus surface), which ends the whole VR session - the exact
failure the relay exists to prevent.
- systemd restarts the service, but Type=notify with READY only after
the virtual devices exist makes the restart a visible hiccup, and a
crash loop from repeated bad datagrams would flap SteamVR's input.
Reproduced by running the relay with stubbed uinput devices on a
non-Linux host and sending "watch abc" to the control socket: it exited
on the first datagram after answering "devices" correctly. After the
fix, the same exchange gets a log line ("bad control datagram ...") and
the relay keeps answering.
The fix wraps each datagram's dispatch in try/except inside
handle_control's loop, so one malformed message is logged and skipped
while the rest of the queue is still processed. Parsing of the common
helper commands (vrbtn, vrhello, gazeawake, keyboard) keeps its own
guards; the catch-all is only a backstop for anything the guards miss,
including float() on a non-numeric argument to "watch" and "vrcapture".
Adapted for experimental (from PR #8): the guard also wraps the textfield
command, which experimental added to this dispatch after the PR's base.
(cherry picked from commit ca7e58069b)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A profile is a named layout plus the screens it hides and its apps' windows
(docs/profiles.md). ft-layout save captures them (ft-floatd's "windows",
after the KWin script reports every window as it is now); use opens them:
the screens move, open windows of each app go to their places (on a screen,
maximized or not, or floating), and missing apps start, once and then again
for each window still missing 3 s after the first. Nothing closes.
The desktop starts in FT_PROFILE or default_profile (ft-layout start, from
the session script). Each profile gets a launcher entry (Frametop: NAME, in
SteamVR's Launch a program list) that switches to it or starts the desktop
in it. The relay's profile:NAME action and Input Settings' "Open profile"
entries put one on a key, mouse button, or controller button. Display
Settings' Layout page becomes Layout & profiles: Save as profile, Open
profile, the profile's apps, and Start in profile. Plasma's own session
restore is off in the Frametop session.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The input relay owns the float key now: float_toggle (Meta+Shift+F unless the
rules have their own key combinations) floats the window under the pointer, or
the active one over the wallpaper, and docks it if it floats. dock_all puts
every floating window back. Both are mappable to mouse and controller buttons,
and work without pointer mode. The KWin script no longer registers a shortcut,
and ft-floatd drops the old one. Key combinations move to Input Settings'
Keyboard page.
docs/floating-windows.md records the decisions from 2026-09-30 (the title bar
button, Launch as Standalone, profiles), and docs/profiles.md plans profiles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steam reads the Frame controllers itself, outside SteamVR's bindings, and every
press and release it sees takes SteamVR out of laser mode, so controller clicks
at the gaze can't be done cleanly (docs/gaze-controllers.md, from the gaze-first
branch's tests).
- Gaze precision and Gaze drag take a mouse button or a key combination only;
the controller source, its aim steering, and POINTER_PRECISION_GAIN,
POINTER_PRECISION_DEADZONE and POINTER_GAZE_DRAG_GAIN are gone.
- The gaze actions (including Gaze pointer on/off) can't be mapped to controller
buttons: the relay ignores them there and doesn't ask the helper for those
buttons, and Input Settings no longer offers them.
- Last used wins in gaze mode too: picking up a controller hands it the laser,
as docs/design.md already said.
- The gaze dot shows all the time (POINTER_GAZE_DOT=always, the default;
moving brings back the old behaviour), with a switch on the Gaze page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two new actions for mouse buttons, controller buttons, and key
combinations: gaze_precision (hold: the pointer stops where you look and
the button's device steers it, a controller by where it points at
POINTER_PRECISION_GAIN, the mouse by its moves; release: click there) and
gaze_drag (the same with a real press at once, dragging until the
release). The relay sends "precision|gazedrag <source> 1|0" to the
helper, which runs them through the same holds as pinches and grips.
- Key combinations on any keyboard ("key_bindings" in the rules, e.g.
Ctrl+Alt+G for gaze on/off): the last key isn't typed.
- POINTER_GAZE_MOUSE: in gaze mode the left button is a precision button
(the default, as before) or clicks right away (direct).
- In gaze mode a moving controller no longer takes the pointer away.
- POINTER_ROLE (right, left, stylus): the driver takes a "role" command,
so the pointer can stay off the hand holding the precision controller,
which would otherwise take the role back. Needs the rebuilt driver.
- Input Settings: the actions on the Controllers and Buttons pages; on the
Gaze page the mouse choice, the role, the precision sliders, and key
combinations (captured from any keyboard).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The palm-down filter is off by default: the user's deliberate pinches,
hand raised, read 0.90-0.99, like typing.
- Typing is told apart by the keyboard instead: the input relay sends the
pointer helper "typing" on key presses, and it takes no pinch within
POINTER_PINCH_TYPING (1 s) of one.
- Grips are still held to hands raised (POINTER_GRIP_BELOW, 0.35 m below
the eyes); pinches aren't, since the user's own sat 0.35-0.45 m below,
elbow resting.
- A grip doesn't begin with the thumb on the index tip: that's a pinch
with the other fingers curled, which was taken for a grip.
- A pinch's point is the index and middle knuckles: the tips' midpoint
moved 1-2 cm as the pinch opened, dragging every release off its press.
- ft-hands --gesture-log prints what the detectors measure, 10 times a
second.
Pinches still aren't reliable enough to use; hand tracking is parked for
now in favour of the controllers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes#7: SteamVR's keyboard never came up for the desktop's apps, and
opening it for our panels doesn't work well on the Frame (it's Steam's own
panel, mounted in the dashboard's scene, it follows the laser between
panels, and it takes the controllers over to SteamVR's laser).
- KWin starts input/ft-textinput as the desktop's input method. It tells
the input relay when a text field gains or loses focus, and the relay
asks ft-screens to open or close the keyboard. The session drops the
QT_IM_MODULE=xim and GTK_IM_MODULE=xim that the gamescope session sets,
or Qt and GTK apps never report text fields.
- The keyboard is ft-screens' own panel (screens/keyboard.cpp): a US laptop
layout, typed with a controller's laser or the 3D mouse. It opens 0.7 m
in front of you, below your eyes and facing you. It has a grab bar to
move it, a Close key, latching Shift, Ctrl and Alt, and repeat on a held
key. It's drawn into shared DMA-BUFs, so it doesn't flicker. Its keys
reach the focused screen as key presses, so every app takes them.
- It steps aside while the Steam menu or Steam's own keyboard is up and
comes back after. A layout reset closes it, and it doesn't open without
a head pose.
- Frametop Input Settings has a Keyboard page: open it for every text
field, only while no keyboard is connected (the default), only from a
mapped button (the new Open/close keyboard action, for mice and
controllers), or never. A switch keeps it open until you close it.
- The pointer helper treats every frametop.* overlay as a real panel. The
keyboard's shared texture reports 0x0 like SteamVR's scene-graph
controls, and the helper had given it their wide catch radius.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Frame controllers aren't input devices on the host, so the pointer
helper reads them with SteamVR input (vrbuttons.h, actions/), one action
set per button, and sends presses to the relay, which does the mapped
action like for a mouse button. Only mapped buttons are taken, at an
overlay-global priority (SteamVR's experimental "Enable global input from
overlays"), and only outside games unless In games is on. Frametop Input
Settings gets a Controllers page to map them.
Gaze mode: a left press while the gaze has the pointer isn't sent at
once. The pointer stops, you drag it onto what you meant with the button
held, and the release clicks there; a press held still for
POINTER_GAZE_HOLD (0.5 s) becomes a real press, for drags. The dot shows
only while the mouse moves it (POINTER_GAZE_SHOW), while a press is held,
and as a pulse per click. Outside games the pointer stays on while gaze
mode is on. ft-gazed takes a third of each lesson's offset instead of all
of it: in the first live test one 6 degree lesson moved everything and put
the next target 7 degrees off. Frametop Input Settings gets a Gaze page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MAGIC pointing (Zhai et al. 1999), off by default: with POINTER_GAZE=1,
"gaze on", or a button mapped to the new gaze_toggle action, the cursor ray
is the corrected gaze from ft-gazed while the gaze has the pointer. Moving
the mouse takes the pointer from where the gaze left it; looking more than
POINTER_GAZE_RETAKE (5 deg) away with the mouse still gives it back. The
pointer is aimed at the gaze, never steered toward it, and only fresh gaze
moves it, so a stopped service or a blink leaves it where it is. A mouse
nudge of up to POINTER_GAZE_NUDGE_MAX (8 deg) before a click is sent to
ft-gazed as a lesson in the eye tracker's error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Off by default. With POINTER_FOLLOW=1 (a switch on the Pointer page of
Frametop Input Settings, or a mouse button mapped to Head follow on/off),
the cursor rides on a reference direction leashed POINTER_LEASH_DEG (10)
from where you face. Within the leash it stays put in the room; past it,
it turns with your head and keeps its offset. A leash of 0 locks it to
your view. Head roll is ignored, the cursor stays within 40 degrees of
the reference, and it holds still in the room while the left button is
down so the head can't nudge a click or a drag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An ungrabbed keyboard reached both sides at once: gamescope, which in VR
reads every input device itself (the SteamOS build's InputStealer), typed
into its focused app, and ft-screens typed into the desktop. Space in the
desktop paused Spotify on the dashboard, including every space frame-voice
dictated. And while the SteamVR dashboard was open, the desktop got no
keys at all.
Typing now follows the last click. ft-screens sees clicks on its own
screens; the pointer helper reports the panel under the dot on each mouse
press, so a click on any other panel sends typing to Steam. While typing
goes to the desktop and the screens are showing, ft-screens tells the
relay every second, and the relay grabs pass-through keyboards. A grab
waits until no key is down, and the relay lets go if ft-screens stops
reporting. Controller clicks on other panels aren't visible to overlay
apps, so they don't move typing (noted in the README).
A program that reads every keyboard for a hotkey loses a grabbed one.
Repeating the keys on another input device doesn't work, since gamescope
reads that too, so with SHARE_KEYS=1 the relay sends them to
@frametop_keys as datagrams with the device name. It's off by default:
any local process that binds that name first would get every key typed
into the desktop.
The docs now say gamescope reads keyboards and the headset's buttons
itself; they said SteamVR passed keys on to it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gamescope sends volume up and down to Steam by moving keyboard focus to
Steam for the key and back. When nothing had focus, it moves focus back to
null and wlroots aborts, which ends the whole VR session. One press of the
headset's volume button did that while working in the Frametop desktop.
The input relay now handles volume keys from every device that has them,
the headset's buttons included, and steps the default output with wpctl
(5%, repeating while held). SteamVR, which passes keys on to gamescope,
never sees a volume key: devices with a keymap (gpio-keys, USB and
Bluetooth keyboards) get only their volume entries remapped to unused
codes, so the headset's click button keeps working, and pmic_resin, which
has only volume down, is grabbed. The keymaps go back when the relay
stops, and --no-grab leaves the volume keys alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Meta tap on a pass-through keyboard toggled the SteamVR dashboard, which
got in the way of using Meta on its own. It's now off unless
META_DASHBOARD=1 is in ~/.config/frametop.conf. Meta as a modifier
(Meta+Shift+R, Meta+Shift+H) is unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The relay scanned /dev/input once a second and only probed paths it hadn't
seen. A Bluetooth mouse that disconnects and reconnects within that second
often gets the same event numbers back, so the relay never grabbed it
again and the mouse stopped working. Nodes are now tracked by inode, so
a re-created node is probed even in a known place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service and menu-entry templates still pointed at the old frametop/
subfolder, so the pointer helper, input relay, and settings apps couldn't
start after a clean install. The pointer helper's install step also failed
when the service was still starting after 3 s; it now waits up to 20 s and
doesn't abort the installer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Several KDE Plasma screens floating in SteamVR, each a real monitor of any
resolution and shape, shown by our own compositor (ft-screens), with a
layout, wrist pinning, and visibility modes; a Bluetooth mouse that drives
all of SteamVR as a room-anchored 3D pointer (input relay, ft-pointer
helper, ft_pointer SteamVR driver); two settings apps; and Bluetooth LE
fixes. Installs on the headset with ./install.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>