Compare commits

...
65 Commits
Author SHA1 Message Date
DeeJanuzandClaude Opus 5.5 9b11162b61 Merge branch lighting-prompt (the recorder asks for Daylight when sunlight comes in) into experimental
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:26:42 -06:00
DeeJanuzandClaude Opus 5.5 e2f51477a3 Hand recorder: ask for Daylight when sunlight comes in; the cameras can't tell it
The first daylight round (dataset PR #5, a room with big sunlit windows) read 2.38 ambient IR
and was labelled indoor: the windows are a small part of each picture, so the dark frames' mean
barely moves. The checklist no longer says the cameras tell daylight themselves, and the docs
say how review corrects the label (hub_review.py lighting) from how much hands stand out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:19:30 -06:00
DeeJanuz 4ba49af487 Merge branch fix/supertuxii-pr41 (our fixes for PR #41) into experimental 2026-10-06 08:54:35 -06:00
DeeJanuzandClaude Opus 5.5 82fbc6fc1b Docs: the relay's mouse buttons follow the pointer, and media keys reach the desktop
reference.md's ft-screens keys paragraph, the relay's top docstring and
hazards.md's key releases section now say that a mouse button passed
through as a key in pointer mode goes to the screen the pointer is on,
not where typing goes, only while one shows and Frametop isn't paused,
that ft-screens lets its release through and releases it itself when
the pointer leaves the screens, its screen hides, or Frametop pauses,
and that a relay that starts releases mouse buttons too. hazards.md
adds that the button still reaches the virtual mouse, which gamescope
reads, and that a keyboard's media keys come from its Consumer Control
node, which stays ungrabbed, so they reach gamescope as well as the
desktop even while typing goes to the desktop. design.md says why the
buttons follow the pointer: as keys, a release was lost whenever typing
moved, and wlroots counts presses per button. reference.md names
scripts/test-relay-buttons.sh, and keys-test's wider scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:52:41 -06:00
DeeJanuzandClaude Opus 5.5 8bd2ac4c92 Input relay tests: which keys and mouse buttons reach the desktop
keys-test gains two fake nodes with volume keys, a keyboard's Consumer
Control node (USB) and the headset's gpio-keys (BUS_HOST), both as if
their volume keys were remapped. Its virtual devices now record what
they're sent, and the relay's volume handling is a recorder, so no
wpctl runs.

New checks: a relay that starts releases the modifiers and mouse
buttons on the desktop. A media key reaches the desktop and no virtual
device, and one held over the once-a-second check stays down until its
node lets go. A volume stand-in goes to the volume handling only; an
original volume key a remap missed, and the headset's KEY_SELECT, reach
nothing. Clicks with POINTER=0, clicks while paused, and a pass-through
mouse's clicks don't reach the desktop; a plain click in pointer mode
is the helper's alone. A side button mapped to "key" in pointer mode
reaches the desktop and the virtual mouse, and its release still
reaches the desktop after a pause starts.

51 checks, all passing. Against the relay at the PR merge (82979e4), 9
of the new ones fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:51:39 -06:00
DeeJanuzandClaude Opus 5.5 ac692c38ea ft-screens: an offline test of the relay's mouse buttons
screens/tests/relay-buttons-test.c checks relay-buttons.h, built and
run by scripts/test-relay-buttons.sh in the dev container like the
controller click test (-Wall -Wextra -Werror). Which codes go to the
keyboard, the pointer, or nowhere (0x100-0x10f, 0x118-0x15f and
BTN_TRIGGER_HAPPY up dropped, KEY_OK up to BTN_TRIGGER_HAPPY keys); a
press without the pointer on a visible screen dropped along with its
release; a release without a press ignored; a release that still goes
once typing or the pointer moved elsewhere; and ft-screens' own
releases, after which the relay's find nothing held.

It fails against a header whose release needs the pointer, and against
one that makes every code from BTN_MISC up a button, as PR #41 did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:48:49 -06:00
DeeJanuzandClaude Opus 5.5 d6556ca813 ft-screens: the relay's mouse buttons follow the pointer, and always come up
PR #41 sent the relay's codes from BTN_MISC up to the seat's pointer
through send_key and handle_key, which lets a release through only if
the wlr keyboard holds that key, and buttons never go through it. So
the release was dropped whenever typing stopped going to the desktop
while a button was held (the dashboard closing in dashboard mode,
looking away in gesture mode, the hide hotkey, a click on another
panel, a pause), and wlroots 0.20 counts presses per button: one lost
release swallowed that button for good, the laser's BTN_LEFT clicks
included, and KWin kept it held. The laser leaving the screens lost it
too, since vr.cpp holds FocusLeave only for its own presses.

The relay's mouse buttons (BTN_MOUSE..BTN_TASK) now follow the pointer
rather than typing, tracked in their own bitmask (relay-buttons.h). A
press needs the pointer on a screen that shows, nothing paused, and
that button not held; a release goes whenever its bit is set. ft-screens
releases the held ones itself when the pointer leaves the screens
(before it clears the focus), when the pointer's screen closes, and on
the first tick after that screen hides or everything pauses
(ft_vr_screen_visible, new in vr.cpp). Each button gets its pointer
frame, and the button state is wl_pointer_button_state, not the
keyboard's enum (-Wextra warned about it).

send_key is keyboard-only again, so relay buttons don't count as typing
for the frame rates (typed_ms). Codes from KEY_OK up to
BTN_TRIGGER_HAPPY take the keyboard path (xkeyboard-config names
keycodes above 255); 0x100-0x10f, 0x118-0x15f and BTN_TRIGGER_HAPPY up
are dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:48:09 -06:00
DeeJanuz 9d66e36439 Merge branch fix/supertuxii-pr40 (our fixes for PR #40) into experimental 2026-10-06 08:47:45 -06:00
DeeJanuzandClaude Opus 5.5 dac4188392 Docs: Native Desktop in the README, the reference, and the uninstall list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:47:37 -06:00
DeeJanuzandClaude Opus 5.5 a9f1bcfdde update-check: say when the Native Desktop copy no longer matches SteamOS's entry
The copy is frozen at install time, and SteamOS's entry and steamos-nested-desktop come from
steamdeck-kde-presets, so that package gets a retest hint too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:47:21 -06:00
DeeJanuzandClaude Opus 5.5 effcfd0e48 uninstall.sh: the Native Desktop entry is listed only when it's there, and alone still counts
The summary always listed it, and when it was the only thing left (step 1 done by an
uninstall.sh from before this entry) step 1 was skipped and the copy stayed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:46:53 -06:00
DeeJanuzandClaude Opus 5.5 3be79efade desktops.sh: Native Desktop is optional, so a SteamOS without the stock entry still installs
Without /usr/share/applications/deckard-nested-desktop.desktop, the sed failed inside the
set -e install script: install.sh stopped at step 7 and left an empty entry, so the get.sh
one-liner broke. Now the copy is skipped with a note. It's written to a temporary file and
moved into place, Name is replaced whole (localized names dropped), and X-Steam-Special
stays on Frametop's entry only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:46:25 -06:00
DeeJanuzandClaude Opus 5.5 030ef2faf1 Input relay: a relay that starts releases mouse buttons on the desktop too
A relay that went away with a key-mapped mouse button down left it down
in ft-screens, and ft-screens takes no second press of a button it
holds. The start's release loop, which let go of only the modifiers,
releases BTN_MOUSE..BTN_TASK as well. ft-screens ignores the release of
a button it doesn't hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:45:50 -06:00
DeeJanuzandClaude Opus 5.5 44c0b2d796 Input relay: mouse buttons go to the desktop only when pointer mode passes them through
PR #41 sent ft-screens every key and button a device has. So with
POINTER=0 every physical click went to ft-screens as well as to the
virtual mouse, clicks were sent while paused, a pass-through mouse's
clicks landed wherever a laser last was, and gamepad, joystick,
digitizer and BTN_TRIGGER_HAPPY codes became desktop buttons.

to_screens() sends keys below BTN_MISC as before, mouse buttons
(BTN_MOUSE..BTN_TASK) only when asked to, and nothing else, but the
release of anything the desktop has down. The pointer branch asks for
it only in pointer mode, where reaching it means the button's map says
"key": the PR's case, a side button passed through for Back. Clicks for
the virtual controller or the virtual mouse don't go to ft-screens,
and a key-mapped button held into a pause still has its release sent.
Keys from KEY_OK (0x160) up stay in the relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:45:43 -06:00
DeeJanuz 56aa3e4b4b Merge PR #40 from SuperTuxii/copy-native-desktop: desktops: Copy Steam's nested desktop as native desktop 2026-10-06 08:45:28 -06:00
DeeJanuzandClaude Opus 5.5 977e9a0950 Input relay: only a keyboard's media keys leave a volume node, for the desktop
PR #41 let volume-role nodes fall into the pointer-device branch. Those
nodes are never grabbed: the headset's gpio-keys (its KEY_SELECT click
button and volume), keyboards' and mice's Consumer Control nodes, and
pmic_resin. So media keys were re-emitted on the virtual keyboard and
gamescope got them twice, the headset's click button went to ft-screens,
the device button maps applied to them, and after a partial volume
remap failure an original volume key went out on the virtual keyboard,
where gamescope reads it (a volume key with nothing focused aborts it).

A volume node's keys stop there again. Only a USB or Bluetooth node's
non-volume keys (the media keys) go on, to ft-screens, like a
pass-through keyboard's: ft-screens types them only while typing goes
to the desktop, and reconcile_desktop_keys releases one whose node goes
away while it's held (physically_down reads volume nodes too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:45:24 -06:00
DeeJanuz 82979e4423 Merge PR #41 from SuperTuxii/allow-more-keys: input: Allow sending keys >= BTN_MISC and allow sending keys from nodes with volume role 2026-10-06 08:40:36 -06:00
DeeJanuz c119442c70 Merge branch fix/0x1f6-pr26 (our fixes for PR #26) into experimental 2026-10-05 17:02:00 -06:00
DeeJanuzandClaude Opus 5.5 fb1c587d69 Gaze: the Gaze page says when SteamVR's eye data has a layout ft-gaze doesn't know
ft-gaze's "eye-server.mmap has eye data, but its layout is not recognized"
reached only the journal. The Gaze page said "the eye tracker isn't
sending", or "no eyes seen (is the headset on?)" for our tracker's first
calibration, which waits for SteamVR to see an eye, so nothing pointed at
the real cause after a SteamOS update.

ft-gazed now notes that line (cleared by ft-gaze's "layout:" line, and when
ft-gaze starts or stops), and the problem under the Gaze pointer switch
says SteamVR's eye data has a layout Frametop doesn't know, so SteamVR's
eye tracker can't be used, or with our tracker, that its calibration can't
open. A calibrated own tracker needs nothing from that file, so it gets no
problem. ft-gaze logs the line only after 30 s of eye data with no layout
fitting, so the tracker's warm-up after the headset goes on doesn't show it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:59:24 -06:00
DeeJanuzandClaude Opus 5.5 102e859cf8 update-check: the eye tracker check knows both eye-server.mmap layouts
With PR #26 ft-gaze reads the SteamOS 0.4.x beta's layout too, but
update-check.py still knew only stable's: on the beta it reported FAIL (warn
without gaze) and said to "update the offsets in gaze/ft-gaze.cpp", as in
issue #15's report, though the gaze worked.

It now tries both layouts with ft-gaze's own test (EyeFile::Detect: the
timestamp within 2 s of the clock and moving on, both set-1 directions unit
vectors), says which one it found, and fails only when neither fits, for
1.5 s so a blink or a moment with an eye lost doesn't fail it. The failure
says to add the new layout to EyeFile::Detect, and to check again after the
tracker's warm-up when the headset just went on. EYE_NEED is ft-gaze's
kNeed now (it was 0x1d3 against ft-gaze's 0x1f3, now 0x1f8). design.md's
update-check sentence and the offset table in gaze/tracker/findings.md say
there are two layouts.

Checked with made-up files: stable and beta ok, the fields moved by 3
bytes, and a direction always zero, FAIL, an eye lost for two samples ok,
an idle counter skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:57:37 -06:00
DeeJanuz b21dfd1bb0 Merge branch fix/0x1f6-pr35 (our fixes for PR #35) into experimental 2026-10-05 16:56:34 -06:00
DeeJanuz bcf8516ebd Merge branch fix/0x1f6-pr34 (our fixes for PR #34) into experimental 2026-10-05 16:56:34 -06:00
DeeJanuz db0d526f81 Merge branch fix/0x1f6-pr33 (our fixes for PR #33) into experimental 2026-10-05 16:56:34 -06:00
DeeJanuz 81f24a5d04 Merge branch fix/0x1f6-pr32 (our fixes for PR #32) into experimental 2026-10-05 16:56:34 -06:00
DeeJanuz 55f00f300d Merge branch fix/0x1f6-pr30 (our fixes for PR #30) into experimental 2026-10-05 16:56:34 -06:00
DeeJanuzandClaude Opus 5.5 b2d32e8934 Gaze: an offline test of ft-gaze's eye-server.mmap layout detection
gaze/test/mmap-layout-test.sh builds mmap-layout-test.cpp against
ft-gaze.cpp itself in the dev container and plays the loop's passes on a
made-up file with a made-up clock (Detect takes the time), so it needs no
SteamVR and no eye tracker. It checks that both layouts are found from the
next sample, at 90 and at 15 samples a second (as seen on the beta), and at
3; that a writer too slow to confirm, a stopped one, one that stopped just
now, a direction that isn't a unit vector, an empty file and the same fields
moved by any other amount from 1 to 16 bytes are all refused; that a
timestamp read mid-write doesn't end the wait; that a sample is read from
the beta's moved fields once that layout is found; and that the counter
reads 0 without the file instead of touching memory.

Checked against three broken copies of ft-gaze.cpp: without the "timestamp
moved on" check (6 failures), with a 60 ms window (2), and with the
counter read unguarded (a segfault).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:55:41 -06:00
DeeJanuzandClaude Opus 5.5 f6b4323669 Docs: the float script's poll watchdog, and callDBus dropping failed calls
design.md's notes on KWin's script engine get the quirk the watchdog from #35
is for: callDBus never calls back when a call fails (an error reply, the name
gone from the bus, or the 25-second timeout), and KWin only logs it. The long
poll's description says how the watchdog calls again (after 30 seconds, then
longer each time while no reply comes) and why the 30 seconds have to stay
above KWin's timeout. floating-windows.md's line on the poll says the script
also calls again after 30 seconds without an answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:55:31 -06:00
DeeJanuzandClaude Opus 5.5 903abd75dd frametop-float: a gone ft-floatd costs one line, and fewer polls
While ft-floatd isn't running, the watchdog fired every 30 s for as long as
the desktop ran, and each fire was two lines in the journal: the script's own
and KWin's "Received D-Bus message is error" for the call that failed. That
was about 5800 lines a day, for nothing: only starting ft-floatd again helps,
and it reloads the script when it starts. ft-floatd starts from the session's
autostart, and nothing restarts it.

The script now says it once per outage, and the next reply clears that. Each
fire after the first waits twice as long, up to 5 minutes, which also cuts
KWin's line. A reply starts over at 30 s, so a lost reply is still polled
again after 30 s.

In the poll model: ft-floatd gone for 10 minutes went from 20 fires and 20
prints to 4 fires (after 30, 60, 120 and 240 s) and 1 print. A lost reply
still recovers at 30 s. Two lost in a row, with no reply between them, now
wait 30 and then 60 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:54:53 -06:00
DeeJanuz f65afce59a Merge remote-tracking branch 'origin/experimental' into experimental 2026-10-05 16:54:19 -06:00
DeeJanuzandClaude Opus 5.5 1bb8e73cb9 frametop-float: a late reply can't start a second poll
The watchdog from #35 starts a new NextCommand call when the old one hasn't
answered. If the old call's reply still came after that, its callback started
another poll, and two polls would answer each other for good: ft-floatd answers
a waiting poll empty whenever the next one arrives, so KWin and ft-floatd would
trade D-Bus calls in a loop until the script reloads. Each call now has a
serial. A reply to a call the watchdog gave up on still runs its commands
(ft-floatd sends each one once), but only the current call polls again.

With the period at 30 s that reply can only come if KWin stalls with it
queued: KWin's callDBus ends every call by its 25 s D-Bus timeout. The period
was ft-floatd's POLL_SECONDS plus 10 s, though, so lowering POLL_SECONDS
below 15 would have made the loop easy to reach. The period is now a plain
30 s, and the comment says it has to stay above KWin's timeout.

In a model of the poll (the script's code run in Qt's JS engine against
ft-floatd's wait() and QtDBus's timeout): with the watchdog at 15 s and
ft-floatd blocked for 15 s, the old code looped (5000 calls in 10 s), and
this one makes 2. The same with KWin stalled and the timer run before the
queued reply. In normal use nothing changes: the same calls, no fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:54:08 -06:00
DeeJanuzandClaude Opus 5.5 0821b1013e Settings: HANDS_SWAP_SIDES defaults to auto, and old untouched configs move to it
frametop.conf.example said HANDS_SWAP_SIDES=0, and desktops.sh copies it on a
fresh install, so every new install forced ft-camd's side camera names and
turned the hand tracker's own side check off. Some SteamVR restarts swap those
names, and then hands land beside their cutouts and recordings are mislabelled.

The example now says auto. scripts/conf-migrate.sh, run by install.sh and
hands/rec/install.sh, replaces the old line only where it's still exactly as
the example wrote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:54:03 -06:00
DeeJanuzandClaude Opus 5.5 3cccad3525 Hands: label side cameras by the hands when a forced HANDS_SWAP_SIDES disagrees
With HANDS_SWAP_SIDES set to 0 or 1, ft-hands kept the forced naming as the
published truth even after the hands showed it was backwards. The hand recorder
took that as the session's decision, so the export labelled slam_left and
slam_right the wrong way round (dataset PR #4: every take swapped).

ft-hands now publishes the hands' answer once they disagree (state "forced,
disagrees"); tracking keeps the forced names. sides.read_live corrects the same
case from an ft-hands built before this, and the recorder's log says to use auto.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:54:03 -06:00
DeeJanuzandClaude Opus 5.5 845bff0037 Docs: pausing releases a click held on the 3D mouse
The pause section of reference.md now says the relay releases a click
still held as it lets go of the 3D mouse, and lists
input/test/pause-buttons-test.py with the other pause test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:53:58 -06:00
DeeJanuzandClaude Opus 5.5 2ede132b62 Input relay tests: what standing down sends, in order
pause-buttons-test checked the pointer's driver_down set and filtered
the sent commands down to "btn" lines. On the code before the fix it
stopped at a missing attribute, before any behavior was checked, and it
couldn't see where "hide" went. The order matters: a release after
"hide" wakes a helper that wakes on any btn.

It now compares the exact commands stand_down sends (["btn trigger 0",
"hide"] for a held left button) and adds the cases that were missing:
the pointer already off with a button held (the idle timeout,
pointer_toggle), a mapped controller button and a key combination, and
gaze holds, which are the helper's to end. Its labels now say what each
case is: they called a plain mouse press a gaze drag. Against the relay
before the fix, 5 of its 11 checks fail; against the contributor's
commit, the 2 pointer-off ones.

keys-test gains a pause check through the relay's main(): in pointer
mode, a left button held into a pause comes up, then the pointer hides,
its release during the pause reaches no one, and clicks go to the helper
again after resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:53:45 -06:00
DeeJanuzandClaude Opus 5.5 a70463d95b Hand tracking: ft-camd keeps its capabilities while the Hand Recorder or the services use them
Two installers give ft-camd its capabilities: hands/run.sh install and the
Hand Recorder's (through hands/run.sh caps). #30 made hands/run.sh uninstall
take them back unconditionally, before it removed the units. With the
recorder installed too, its next session then stopped at "ft-camd needs its
capabilities", and so did ft-cutouts. The recorder's own uninstall, the one
hand-recorder.md points to, still left them on the binary.

One rule now: hands/run.sh uncaps takes them back only while neither the
Hand Recorder's menu entry nor the frametop-camd unit is installed, and says
which one keeps them otherwise. It checks through on_frame, in one call, so
it works from a PC too. hands/run.sh uninstall runs it after removing the
units, and hands/rec/install.sh uninstall after removing the entry, so each
stops counting itself. uninstall.sh removes both and always takes them back.

The docs say which commands ask for the password, and AGENTS.md says
uninstall.sh also has to undo file capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:53:33 -06:00
DeeJanuzandClaude Opus 5.5 a84889b4a5 Gaze: ft-gaze looks for the eye-server.mmap layout once, without stalling its loop
PR #26's detection slept 60 ms per layout inside ft-gaze's 4 ms loop, so no
head poses and no SteamVR events in that time, and it needed a new sample
inside those 60 ms: at 15 samples a second (seen on the beta) about 1 try in
10 missed, at 10 a second half of them. It also ran once at startup and
again on the loop's first pass, and again after every pause of 2 s or more,
where a failed try (SteamVR's tracker warming up after the headset went on)
threw away a layout that was known. That can't help: the layout changes
only with a new eye server, which comes with a SteamVR restart, and the gaze
service stops and starts with SteamVR.

Detect is now a step per pass: it notes the layouts that fit (timestamp near
the clock, unit set-1 directions, as before) and takes one once its
timestamp has moved on, within 0.5 s, so down to 2 samples a second. It runs
only while no layout is known, starting when the counter moves, and the
counter is read at startup so a file left from a stopped server doesn't
start it. "Not recognized" goes to the journal only after 30 s of writing
with no layout fitting, past the tracker's warm-up (about 20 s), so the
warm-up no longer logs it. The startup line with the layout is one of the
probe's startup lines now, not an error worth repeating when ft-gaze stops.
TimePlausible is static, and the size check Open's kNeed already covers is
gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:53:14 -06:00
DeeJanuzandClaude Opus 5.5 8b9edcfbe2 Pointer: a click the pointer dropped as it went off isn't a right click later
In gaze mode a mouse press is held back while you aim, and its release
clicks. A release of the right button sets pressRight with the click, so
the click goes out as a right one. When the pointer goes off in the same
loop (the relay's stand_down sends the release, then "hide", as Frametop
pauses), the click is dropped, but pressRight stayed set. The next press
after resume, from the mouse, Meta+J or a pinch, then went out as a
right click.

Dropping the click now forgets pressRight with it. A "hide" read a loop
after the release still comes too late to stop the click; that needs the
helper's socket to be full as the pause starts, and is noted in the
relay's stand_down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:51:32 -06:00
DeeJanuzandClaude Opus 5.5 0f1132ed27 Gaze: an eye-server.mmap layout ft-gaze doesn't know keeps our own tracker going
With PR #26, ft-gaze printed nothing at all while the mmap's layout wasn't
known: not on a layout it doesn't recognize (the next SteamOS update that
moves the fields), and not while SteamVR's tracker warms up after the
headset goes on, when the counter already ticks but the timestamp and the
directions aren't usable yet. Our own tracker, the default, needs nothing
from that file, but its gaze rides on ft-gaze's lines, so it stopped too.

Without a usable mmap (none, or its layout not known yet) ft-gaze now prints
at 90 Hz, as it always did without the file, and the mmap's sources print
as {"ok":0} and "eye" as null. They have to stay out: the unread sample's
zeros would print an "unc" of 0, which gazecheck takes as SteamVR seeing
both eyes, and checks would start on nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:51:17 -06:00
DeeJanuzandClaude Opus 5.5 4828dd46b5 Pointer: a release never wakes the pointer
Any "btn" or "scroll" from the relay woke a pointer that was off. When
Frametop pauses for a game, the relay's stand_down sends the releases of
the clicks held into the pause, and with its pointer already off (the
idle timeout or pointer_toggle during a press) it used to send them with
no "hide" after. The helper woke on them, connected the virtual
controller and took the right hand role during the game.

Now a button release or a scroll back to 0 0 doesn't wake it. The
release is still handled as before (it ends its press, or goes to the
driver), so the driver doesn't reconnect later with the button down.
Outside a pause this changes one case: a mouse button let go after a
moving controller released the pointer mid-press. That release used to
reconnect the controller, recenter it and release there; now it updates
the disconnected controller, and ft-screens still gets its "up".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:51:09 -06:00
DeeJanuzandClaude Opus 5.5 d4e5e2b442 fix-panels: a second backup holds the config from before each repair
Since b61b4d6, <file>.ft-bak is written only before the first repair. That
keeps the first config, but no repair after it had a backup, and the log
still said "(backup: <file>.ft-bak)" for each one. The repair writes 0 over
a moved panel's old screen number, so a backup is the only record of where
the panel was. Restoring the file the log named after a later repair also
threw away every Plasma change since the first one: panels, widgets, pinned
apps, wallpapers. Repeated repairs are the case b61b4d6 was written for.
Tried on a copy: after a first repair, a new top panel on screen 2 with
pinned apps, and a drop to one screen, the second repair moved that panel
and named a backup that had neither.

Before each repair the config now also goes to <file>.ft-bak.last (whole or
not at all, like <file>.ft-bak). <file>.ft-bak stays write-once. The log
names both, and the docstring and docs/design.md describe both. Both live
in ~/.config/frametop, which uninstall.sh already deletes with the
settings.

The new test repairs twice, with a widget changed between the runs, and
checks that .last holds the changed config, .ft-bak the first one, and the
log names both. test_repair checks that the first repair writes both and a
run with nothing to repair writes neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:51:03 -06:00
DeeJanuzandClaude Opus 5.5 82da3525b7 Gaze: ft-gaze runs without the eye tracker's file, and opens it once it appears
PR #26's layout check read the sample counter on every pass of the loop,
also when /dev/shm/eye-server.mmap wasn't mapped. Without the file the
mapping is a null pointer, so ft-gaze died with SIGSEGV on its first pass,
and the gaze service started it again every 3 s: another container-up.sh,
distrobox enter and SteamVR client each time, while the path that prints at
90 Hz without the file (our own tracker needs nothing from it) could no
longer be reached.

The counter is now read only through the mapping (EyeFile::Counter, 0
without one). And since SteamVR's eye tracker creates the file a second or
two after SteamVR starts, an ft-gaze that started first now looks for it
again every 2 s, one open each time, instead of going without it until the
next restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:50:33 -06:00
DeeJanuzandClaude Opus 5.5 2a2bca336c Input relay: standing down always hides after a release
stand_down sent "hide" only while the relay's pointer was on. It can be
off with a button still held: the idle timeout or pointer_toggle during
a press. Then the pause sent "btn trigger 0" on its own, the helper woke
on it (any btn or scroll wakes it), connected the virtual controller and
took the right hand role during the game, and nothing hid it again: the
relay doesn't tick a paused pointer.

Now every release stand_down sends (held buttons, the system and claim
pulses, a scroll pulse) is followed by "hide". The helper also stops
waking on releases (next commit), but the relay and the helper update
separately, so the relay can't count on that.

The comments now say what driver_down covers: the left, right, middle
and back actions from a mouse, a mapped controller button or a key
combination. Gaze holds go to the helper as gazekey, gazedrag and
precision, and it ends them itself when the pointer hides. The docstring
also records the one case left: a gaze-mode press held back for aiming
clicks once if the helper reads "hide" a loop after the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:50:14 -06:00
DeeJanuzandClaude Opus 5.5 cd47dde566 fix-panels: the backup is written whole or not at all
shutil.copy2 wrote <file>.ft-bak in place, so a copy cut off partway (a
full disk, a crash, the battery running out) left a short file. Since
b61b4d6 the backup is written only once, so that short file stayed for
good: os.path.exists finds it, the next repair skips the backup and goes
ahead. Before, the next repair at least wrote it again. With a 1 KiB limit
on file size, the first run left 1024 of the config's 2536 bytes, and the
second kept them and repaired.

The copy now goes to <file>.ft-bak.tmp, is fsynced, and only then takes its
name with os.replace; a failed copy removes the temporary file. If the
backup can't be written, the panels stay as they are, the reason goes to
the log, and the script exits 1 (the session goes on: it runs fix-panels
with || true). The next start tries again. That was already the outcome
of a failed copy, but through a traceback.

The new test runs fix-panels under that 1 KiB limit and checks that only
the config is left in its directory, unchanged, and that the next run
without the limit backs it up and repairs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:50:13 -06:00
DeeJanuzandClaude Opus 5.5 a5d001d4b5 uninstall.sh: a re-run clears ft-camd's capabilities too, and spaces can't split its path
Step 1 ran only while something it lists was left, and ft-camd's capabilities
weren't part of that test. When they were the only thing left, a re-run
skipped step 1 and never removed them. That happens to everyone who ran step 1
with the uninstaller from before #30, and to a re-run after the password
failed. Now they count like the rest.

The folded sudo call passed ft-camd as FTCAMD=$(...), an unquoted word that a
repo path with a space split in two: sudo then ran the second half as the
command, and the system files stayed. ft-camd now goes to the root script as
its first argument, quoted, which also stops depending on sudoers letting
VAR=value through. The capabilities-only warning says to run it again, which
works now.

The README and the script's header say step 1 also takes the capabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:49:51 -06:00
DeeJanuzandClaude Opus 5.5 fa65954834 fix-panels tests: check the write-once backup with the real kwriteconfig6
test_backup_keeps_the_original ran fix-panels.py against a 24-line shell and
awk stand-in for kwriteconfig6, and its last check (lastScreen is 0) tested
that stand-in's edit, not fix-panels. The stand-in also ignored --key, so it
would go wrong without a sound the day fix-panels writes another key. The
file's test_repair already needs the real tool, which the Frame and the dev
container both have, and the old test passes unchanged without the stub.

The new test puts a backup in place first and checks that a repair leaves it
as it was and still moves the panel and its tray. It fails on the code
before b61b4d6 (the backup gets overwritten) and passes on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:49:20 -06:00
DeeJanuzandClaude Opus 5.5 c747bea791 ft-screens: say why ft-layout couldn't be started
posix_spawn also fails when the log can't be opened: with 9c66475's
O_NOFOLLOW, a symlink or a directory where the /tmp fallback's log goes
makes it fail with ELOOP or EISDIR, and ft-layout doesn't run. The message
said only "can't run .../ft-layout", which reads as a missing ft-layout.
It now names the log and the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:48:49 -06:00
DeeJanuzandClaude Opus 5.5 e918359c8f report.sh, docs: the layout log is in the host's runtime directory
After 9c66475 and the previous commit, ft-layout's log is
$XDG_RUNTIME_DIR/frametop-layout.log, but report.sh still collected
/tmp/frametop-layout.log, so bug reports lost it, and reference.md still
named the /tmp path.

report.sh reads the new path (it already sets XDG_RUNTIME_DIR to
/run/user/<uid>). reference.md and the comment on RunLayout name the host's
runtime directory, which ft-screens and the desktop start share. The nested
desktop has its own, /run/user/<uid>/frametop, removed at every desktop start
and stop, and the log isn't there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:48:15 -06:00
DeeJanuzandClaude Opus 5.5 c53a4f07e9 Session: the start's layout log goes to the runtime directory too
9c66475 moved the log of ft-screens' ft-layout runs to $XDG_RUNTIME_DIR, but
the desktop start still wrote /tmp/frametop-layout.log with a shell redirect,
which truncates and follows a symlink planted there. The fixed /tmp path stayed
in use at every start, and the layout's output was split over two files, one
of them never truncated.

The start now writes $XDG_RUNTIME_DIR/frametop-layout.log, the host runtime
directory ft-screens gets from this script, so one file has the run from the
last desktop start and ft-screens' runs after it. The script already needs
XDG_RUNTIME_DIR here (set -u; ft-screens' socket check reads it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:47:44 -06:00
DeeJanuz cb34ba45a1 Merge PR #35 from 0x1f6/fix/float-poll-watchdog: frametop-float: a watchdog re-arms the command poll after a lost reply 2026-10-05 15:40:16 -06:00
DeeJanuz 740aededff Merge PR #34 from 0x1f6/fix/layout-log-path: ft-screens: ft-layout's log goes to the runtime directory, not /tmp 2026-10-05 15:40:15 -06:00
DeeJanuz 8655395030 Merge PR #33 from 0x1f6/fix/pause-stuck-button: Input relay: standing down releases the driver buttons the pointer holds 2026-10-05 15:40:14 -06:00
DeeJanuz 6f65145553 Merge PR #32 from 0x1f6/fix/fix-panels-backup: fix-panels: the backup keeps the config as it was before the first repair 2026-10-05 15:40:14 -06:00
DeeJanuz cce37b5835 Merge PR #31 from 0x1f6/fix/vnc-startup: vnc-bridge: create the credentials directory before writing the password 2026-10-05 15:40:13 -06:00
DeeJanuz ff3724cb3c Merge PR #30 from 0x1f6/fix/setcap-residue: Uninstalls clear ft-camd's file capabilities 2026-10-05 15:40:13 -06:00
DeeJanuz 48bceaef64 Merge PR #26 from 0x1f6/fix/gaze-mmap-beta-layout: Gaze: detect the eye-server.mmap layout at runtime (fixes eye tracking on SteamOS beta) 2026-10-05 15:40:12 -06:00
0x1f6 c1ba71aba1 frametop-float: a watchdog re-arms the command poll after a lost reply
The long poll had one failure mode: if NextCommand's reply never arrives
(ft-floatd dying mid-call, a D-Bus hiccup), polling stays set and the script
never hears a command again until KWin reloads it — float and dock buttons
go dead. A single-shot watchdog (ft-floatd's POLL_SECONDS plus 10 s of
slack) re-arms the poll if the reply is late; it doubles as a retry while
ft-floatd is down at startup.

To see it on the Frame: float a window, 'pkill -9 -x ft-floatd', start
ft-floatd again, and float another one within half a minute — before, the
second float was ignored until the desktop restarted.
2026-10-05 23:24:59 +02:00
0x1f6 9c6647538b ft-screens: ft-layout's log goes to the runtime directory, not /tmp
ft-layout's output landed on the fixed path /tmp/frametop-layout.log,
created with O_CREAT|O_APPEND and no O_NOFOLLOW: another local user could
plant a symlink there before ft-screens' first layout call and have ft-layout
append through it. The log now goes to $XDG_RUNTIME_DIR (the desktop's
private runtime dir; the session gives it one), falling back to /tmp with
O_NOFOLLOW, which makes a planted symlink fail instead of being followed.

To see it on the Frame: run a layout change, then check
$XDG_RUNTIME_DIR/frametop-layout.log exists and /tmp/frametop-layout.log
isn't created.
2026-10-05 23:24:59 +02:00
0x1f6 8a77364767 Input relay: standing down releases the driver buttons the pointer holds
Pausing drops button releases (do_action's works_paused check), and
stand_down only ended the pulses it tracks itself: a gaze drag or gazekey
click held down when the pause started left its virtual controller button
(trigger, b, x, joystick) down until resume, pressing things in Steam.

The pointer now remembers which driver buttons it pressed and hasn't
released, and stand_down releases them before it hides. On pre-fix code the
new test aborts ('Pointer' has no driver_down); with the fix all 8 checks
pass, including two buttons down at once and a stray second stand_down.

  input/test/pause-buttons-test.py
2026-10-05 23:24:59 +02:00
0x1f6 b61b4d6c6a fix-panels: the backup keeps the config as it was before the first repair
Every repair rewrote <file>.ft-bak, so the backup held the state of the
previous run, not the original: repair the config twice (the desktop saves
the panel on the lost screen again, with different widgets) and the copy of
the pristine config was gone. The backup is now written once, before the
first repair; later runs leave it alone. The docstring says what it now
really does.

A new test proves it against a kwriteconfig6 stub, so it runs without
Plasma's tools: with the old code it fails (the backup holds the drifted
state), with the new code the pre-first-repair original survives.

To see it on the Frame: session/tests/test_fix_panels.py (all 12, including
the real-kwriteconfig6 test_repair).
2026-10-05 23:24:59 +02:00
0x1f6 edf605f896 vnc-bridge: create the credentials directory before writing the password
remote-ctl.sh starts remote-desktop.sh and vnc-bridge.sh together, but only
remote-desktop.sh creates ~/.config/frametop-remote. When vnc-bridge gets to
its password write first, the redirect to $creds/vnc-password fails under
set -eu and the bridge exits while krdp keeps running: 'remote-ctl status'
reports both, VNC never works for that start. The bridge now makes the
directory itself (0700, like the settings app's os.makedirs).

Repro: rm -rf ~/.config/frametop-remote, run the password block with the
directory missing — before: exit 1, no password file; after: directory 0700
and an 8-character password.
2026-10-05 23:24:59 +02:00
0x1f6 b5fe574ae6 uninstall.sh: clear ft-camd's file capabilities when the code stays
'uninstall.sh' keeps the repo by default, so the ft-camd binary kept the
capabilities 'hands/run.sh caps' gave it (cap_sys_ptrace, cap_perfmon,
cap_dac_read_search): read-any-file powers for whoever executes the file.
When the binary still carries capabilities, step 1 now drops them — folded
into the existing sudo call when the system files need removing anyway, on
its own otherwise (and only then). With the repo deleted, step 2 already
removes the file with its capabilities.

Repro: install hands, run uninstall.sh keeping the repo, 'getcap
$repo/hands/build/ft-camd' was non-empty afterwards; now it's empty.
2026-10-05 23:24:59 +02:00
0x1f6 76d6542071 hands/run.sh: uninstall clears ft-camd's file capabilities
ft-camd carries cap_sys_ptrace,cap_perfmon,cap_dac_read_search+ep (set by
'run.sh caps'). 'run.sh uninstall' removed the units and the symlink but left
the capabilities on the binary in the repo, which uninstall.sh keeps by
default: a file granting read-any-file powers to whoever executes it stayed
on the headset. Drop the capabilities when the binary still has them. Like
'caps', this needs the password; without one it warns and leaves the rest of
the uninstall intact.

To see it was live: install hands, 'getcap hands/build/ft-camd' shows the
three caps; 'run.sh uninstall' now clears them (getcap empty).
2026-10-05 23:24:59 +02:00
SuperTuxii a94a0fb432 desktops: Copy Steam's nested desktop as native desktop
Copy Steam's nested desktop and rename it to "Native Desktop". This
allows accessing both frametop desktop and the native desktop.

Signed-off-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com>
2026-10-05 22:53:22 +02:00
SuperTuxii b1b1a16f5b input: Allow sending keys >= BTN_MISC and from nodes with volume role
Allow sending keys >= BTN_MISC to screens by handling them as pointer
buttons when receiving them from input relay in the compositor.
Also allow sending keys other than volume keys from nodes with the
volume role.

Signed-off-by: SuperTuxii <123881249+SuperTuxii@users.noreply.github.com>
2026-10-05 22:52:35 +02:00
0x1f6 66160aed55 Gaze: detect the eye-server.mmap layout at runtime (SteamOS beta, +5 shift)
SteamOS 0.4.x beta (SteamVR 2.18.2) moved every field of
/dev/shm/eye-server.mmap from the timestamp on by 5 bytes; the counter at
0x38 kept its place. With the old constants ft-gaze read neighboring fields
as floats, and ft-gazed discarded every sample as broken JSON. Measured on
2026-10-04 (SteamOS 0.4.3 beta, SteamVR 2.18.2, ftdiag scan with an active
gaze session):

  counter (u32)  0x38  -> 0x38   (unmoved)
  time (f64)     0x157 -> 0x15c
  left1/right1   0x15f/0x16b -> 0x164/0x170
  fix1           0x18f -> 0x194
  left2/right2   0x19b/0x1a7 -> 0x1a0/0x1ac
  var1/var2      0x177/0x1b3 -> 0x17c/0x1b8
  open           0x1cb -> 0x1d0
  meas           0x1d3 -> 0x1d8

While eye data flowed, ft-gazed logged ~100% bad samples (whole beta boots:
336k of 337k lines on Oct 3); gaze mode silently fell back to head-only
steering. With this change: 0 bad samples at a steady 15 Hz, and the full
calibration completes (21 of 21 dots) where it previously took none.

Instead of hardcoding either layout (which would break the other generation),
the layout is detected at runtime (EyeFile::Detect): a candidate (shift 0 or
5) is accepted when, at base+shift, the f64 timestamp is within +/-2 s of
CLOCK_MONOTONIC_RAW and advances across ~60 ms, and the set-1 eye directions
are unit vectors (|v| in 0.9..1.1). Both checks together also detect a
co-moved block reliably; a shift of only some unstructured fields (var/open)
would not be locally detectable.

While no candidate fits, ft-gaze emits no samples at all (an unknown layout
still passes the counter/timestamp consistency check, but yields garbage)
and logs "eye-server.mmap has eye data, but its layout is not recognized -
eye tracking unavailable", rate-limited to one line per 60 s. Detection
retries only while the eye server actually writes (the unshifted counter
ticks per sample), so a silent server (headset off, gaze idle) causes
neither retries nor journal noise. It re-detects on its own after the
headset goes back on or the eye server restarts, without ft-gaze
restarting. kNeed grows to hold either layout.

Verified on the beta: layout reported as "beta (+5)" within a second of the
eye server writing, self-healing after idle periods, 0 bad samples with eye
data flowing, full calibration 21/21.
2026-10-04 20:13:48 +02:00
45 changed files with 1270 additions and 142 deletions

No files matched your search

+2 -2
View File
@@ -25,8 +25,8 @@ scripts/frame.sh --host '<cmd>' # runs on the SteamOS host
A Steam Frame is someone's personal headset, and they may be wearing it while you work.
- Don't kill or restart `gamescope`, `steam`, `vrserver`, `vrcompositor`, the gamescope session, or the Frametop desktop without asking. Each one ends or disrupts whatever is happening in VR.
- Don't run host `sudo`, `steamos-readonly disable`, `steamos-devmode` changes, pacman installs, or reboots without explicit approval. Three installers need host `sudo`, and they ask for it: the Bluetooth fixes (`setup/bluetooth/install.sh`), hand tracking (`hands/run.sh install` and `caps`, which set ft-camd's file capabilities with `setcap`), and our own eye tracker's frame grabber (`gaze/tracker/install.sh`).
- Write only inside the repo, `/tmp`, and the container unless told otherwise. The installers are the exception: they write the user services, launchers, and the SteamVR driver into the home folder. The Bluetooth fixes and the eye tracker's frame grabber also install root-owned files and system services under `/etc` (`/etc/steamframe`, `/etc/frametop`, `/etc/systemd/system`). When an installer starts writing something new outside the repo, add it to `uninstall.sh` too: users uninstall with that script, not with each installer's `uninstall`.
- Don't run host `sudo`, `steamos-readonly disable`, `steamos-devmode` changes, pacman installs, or reboots without explicit approval. Three installers need host `sudo`, and they ask for it: the Bluetooth fixes (`setup/bluetooth/install.sh`), hand tracking (`hands/run.sh install` and `caps`, which set ft-camd's file capabilities with `setcap`, and `uninstall` and `uncaps`, which take them back), and our own eye tracker's frame grabber (`gaze/tracker/install.sh`).
- Write only inside the repo, `/tmp`, and the container unless told otherwise. The installers are the exception: they write the user services, launchers, and the SteamVR driver into the home folder. The Bluetooth fixes and the eye tracker's frame grabber also install root-owned files and system services under `/etc` (`/etc/steamframe`, `/etc/frametop`, `/etc/systemd/system`). When an installer starts writing something new outside the repo, or sets file capabilities, add it to `uninstall.sh` too: users uninstall with that script, not with each installer's `uninstall`.
- Never copy `.netrc`, SSH keys, or Steam config off the Frame or into this repo.
## SteamOS updates
+2 -2
View File
@@ -33,7 +33,7 @@ You need a Steam Frame with an internet connection, a keyboard (Bluetooth, or th
The installer sets up distrobox in your home folder (the system files aren't touched), a Fedora build container, and everything else. The first run downloads 1–2 GB. It asks you four things along the way: whether to install gaze mode (experimental, yes by default), our own eye tracker for it (yes by default), and the Bluetooth fixes, then whether to restart SteamVR. The eye tracker and the Bluetooth fixes need your `sudo` password; if you've never set one, run `passwd` first, or skip them for now. SteamVR has to restart once at the end, which closes everything open in VR, including the terminal. Rebooting the headset works too.
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.
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.
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.
@@ -179,7 +179,7 @@ curl -fsSL https://deejanuz.github.io/frametop/uninstall.sh | bash
It works in two steps, so it never takes away the keyboard, mouse, or desktop you're using while it runs:
1. It stops Frametop from starting. Launch a program → Desktop opens the stock desktop again, and Frametop's services, its SteamVR driver, its menu entries, and the system files of our eye tracker and the Bluetooth fixes are removed (those need your `sudo` password). Everything running now keeps running until you restart the headset, and it offers to restart it for you.
1. It stops Frametop from starting. Launch a program → Desktop opens the stock desktop again, and the Native Desktop entry, Frametop's services, its SteamVR driver, and its menu entries are removed, along with the system files of our eye tracker and the Bluetooth fixes and the file capabilities of hand tracking's camera broker (those need your `sudo` password). Everything running now keeps running until you restart the headset, and it offers to restart it for you.
2. After the restart, run the same command again. It deletes the code in `~/frametop`, and asks whether to delete your settings, any eye or hand recordings, and the build container (1–2 GB) too.
To see what it would do without changing anything, add `-s -- --dry-run` after `bash`. If the code isn't in `~/frametop`, add `-s -- --dir <folder>`. From the repo, the same script is `./uninstall.sh`.
+13 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Start, stop, or inspect the multi-screen Plasma desktop in VR on the Frame.
# Usage: desktops.sh start [screens] | stop | restart | status | log [lines]
# desktops.sh install # make the VR launcher's "Desktop" entry start Frametop
# desktops.sh install # make the VR launcher's "Desktop" entry start Frametop, and add
# # "Native Desktop" for the stock SteamOS desktop
# desktops.sh uninstall # give the launcher back the stock SteamOS desktop
# desktops.sh screens N # set the default screen count in ~/.config/frametop.conf
# desktops.sh remote on|off|info # VNC access over the tailnet (applies on next start)
@@ -16,6 +17,8 @@ action=${1:-start}
screens=${2:-${FT_SCREENS:-}}
session=$FRAME_REPO/session
override=.local/share/applications/deckard-nested-desktop.desktop
native_copy=.local/share/applications/native-deckard-nested-desktop.desktop
stock=/usr/share/applications/deckard-nested-desktop.desktop
log=/tmp/frametop-session.log
# Bracketed first letter so pgrep/pkill never match the ssh shell running them.
match='[v]r-overlay-key frametop '
@@ -35,15 +38,23 @@ systemd-run --user --collect --quiet --unit frametop-desktop \
sleep 12; echo \"plasmashell processes: \$(pgrep -c plasmashell)\"
$running && echo 'started' || { echo 'failed:'; tail -20 $log; exit 1; }" ;;
install)
# Also "Native Desktop", a copy of SteamOS's entry for its own desktop. Optional, so a SteamOS
# without the stock entry still installs. No X-Steam-Special, so Steam can only single out ours.
"$root/scripts/sync.sh" >/dev/null
"$frame" --host "set -e; mkdir -p ~/.local/share/applications
sed 's|@SESSION@|$session/frametop-session.sh|' $session/deckard-nested-desktop.desktop > ~/$override
if [ -r $stock ]; then
sed -e 's/^Name=.*/Name=Native Desktop/' -e '/^Name\\[/d' -e '/^X-Steam-Special=/d' -e '/pick this out/d' \\
$stock > ~/$native_copy.new && mv ~/$native_copy.new ~/$native_copy
else
rm -f ~/$native_copy; echo 'no $stock: no Native Desktop entry' >&2
fi
[ -f ~/.config/frametop.conf ] || cp $session/frametop.conf.example ~/.config/frametop.conf
echo \"installed ~/$override\"; grep ^Exec= ~/$override; echo; cat ~/.config/frametop.conf" ;;
uninstall)
# Also what the session puts in place at each start: Launch as Standalone's app copies and
# the title bar decoration (float/ft_apps.py, decoration/).
"$frame" --host "rm -f ~/$override
"$frame" --host "rm -f ~/$override ~/$native_copy
rm -rf ~/.local/share/frametop/apps ~/.local/share/kwin/decorations/kwin4_decoration_qml_frametop
rmdir ~/.local/share/frametop 2>/dev/null; echo 'removed; the launcher uses the stock desktop again'" ;;
screens)
+6 -3
View File
@@ -85,12 +85,13 @@ In a game, Frametop's panels work like SteamVR's own floating windows: point a c
### The KWin script
The KWin side is a script (`float/frametop-float.js`), not a C++ effect, because a script keeps working across KWin updates and an effect would have to match the host's exact KWin build. KWin scripts can call D-Bus but can't serve it, so ft-floatd's commands come back through a long poll: the script calls `NextCommand`, which answers when a command is ready, or empty after 20 seconds, under KWin's 25-second D-Bus timeout. A few things about KWin's script engine:
The KWin side is a script (`float/frametop-float.js`), not a C++ effect, because a script keeps working across KWin updates and an effect would have to match the host's exact KWin build. KWin scripts can call D-Bus but can't serve it, so ft-floatd's commands come back through a long poll: the script calls `NextCommand`, which answers when a command is ready, or empty after 20 seconds, under KWin's 25-second D-Bus timeout. If no reply comes within 30 seconds, a watchdog calls again, and waits twice as long each time until a reply comes (up to 5 minutes). The 30 seconds have to stay above KWin's timeout, or a late reply would start a second poll. A few things about KWin's script engine:
- `windowAdded` reports popups as windows of their own (`popupWindow` true, `transient` true) with their geometry.
- Setting `frameGeometry` applies asynchronously: the app has to answer the new size first.
- A script can't read a window's maximize mode, so the script counts a window as maximized when it fills its output's maximize area.
- `globalThis` isn't defined. `print` goes to the journal unless `QT_FORCE_STDERR_LOGGING=1`.
- `callDBus` never calls back when a call fails (an error reply, the name gone from the bus, or the 25-second timeout). KWin only logs `Received D-Bus message is error`, so a script that waits for the callback waits forever.
The title bar's float button is Frametop's own window decoration (`decoration/`), written in QML for KWin's Aurorae engine, which loads it without compiling. A C++ fork of Breeze would have to match SteamOS's exact KDecoration build. A decoration can only make the window requests KWin offers it, so the button toggles keep-below, which has no visible effect on a window alone on its own output, and the script treats keep-below as the floating flag.
@@ -159,6 +160,8 @@ The relay never waits on the pointer helper. Its socket to the helper used to bl
An ungrabbed keyboard reaches both sides at once. In VR, gamescope reads every input device itself (the SteamOS build's `InputStealer`, libinput with udev hotplug, so new devices too) and types into its focused app, and ft-screens types the same keys into the desktop. So Space in the desktop also paused Spotify on the dashboard. Typing now follows the last click. ft-screens sees clicks on its own screens, from the mouse or a controller. A click anywhere else is only visible for the mouse: overlay apps get SteamVR's `OverlayFocusChanged` (which panel the laser is on) but no controller button events, so the pointer helper reports the panel under the dot on each left press. ft-screens tells the relay where typing goes every second, from an unbound socket so the relay's replies can't loop back into its control socket, and the relay grabs pass-through keyboards while it's the desktop. A grab waits until the keyboard has no key down, so no key stays held on either side, and the relay lets go if ft-screens stops reporting. A program that reads every keyboard for a hotkey (a dictation tool, say) loses a grabbed keyboard. Repeating the keys on another input device doesn't work: gamescope reads that device too, whether it's the relay's virtual keyboard or one created later, and every Space, typed or dictated, paused Spotify again. So with `SHARE_KEYS=1` the relay sends a grabbed keyboard's keys to `@frametop_keys` as datagrams (`key <code> <value> <device name>`). It's off by default, because the relay can't tell who is listening: abstract sockets have no permissions, and any local process that binds the name first gets every key typed into the desktop, passwords included. A listener should accept only its own user (`SO_PASSCRED`) and skip any keyboard of its own that the relay grabs too.
A mouse button that pointer mode passes through as a key (a side button for Back) is a pointer button to ft-screens, not a key, so it goes to the screen the pointer is on rather than where typing goes. As a key, its release was lost whenever typing moved while it was held (the dashboard closing, a click on another panel, a pause), and wlroots counts presses per button: from then on that button, the lasers' left click included, did nothing in the desktop, and KWin kept it held. So ft-screens tracks the relay's buttons itself, lets a release through whenever it took the press, and releases them when the pointer leaves the screens, its screen hides, or Frametop pauses.
Volume keys must never reach gamescope. With the openvr backend, gamescope sends volume up and down to Steam by moving keyboard focus to Steam for the key and then back to the previously focused surface. When nothing had focus, the one it moves back to is null, and wlroots aborts on a null focus surface (`wlr_seat_keyboard_notify_enter: Assertion 'surface' failed`), which ends the whole VR session. Keyboard focus is often empty while you work in VR, so one press of the headset's volume button could take everything down. gamescope reads the headset's buttons and every keyboard itself (`InputStealer`), as do SteamVR's processes, so the relay has to stop volume keys at the device. Grabbing `gpio-keys` would also take the headset's click button, so the relay remaps the volume entries in each device's keymap (`EVIOCSKEYCODE`) and handles the stand-in codes itself. That fix covers every device at once, including keyboards that aren't grabbed.
Frametop's keyboard opens by itself for a text field on the desktop. The apps run inside the nested KWin, so only KWin knows when a text field has focus, and the way it tells anyone is its input method protocol (`zwp_input_method_v1`): KWin starts one input method program and activates it whenever the focused app turns on text input. `input/ft-textinput` is that program, speaking the Wayland wire protocol directly so it needs nothing but Python on the host. It only reports focus. The gamescope session puts `QT_IM_MODULE=xim` and `GTK_IM_MODULE=xim` in the systemd user environment; with those, Qt and GTK apps use X input methods and never turn on Wayland text input, so the session script drops them.
@@ -197,7 +200,7 @@ KWin renders with OpenGL through zink on Turnip, Vulkan on the same GPU vrcompos
The nested session also runs the system's XDG autostart entries, being a KDE session. Discover's update notifier started `plasma-discover --mode update` in it (520 to 620 MB resident and about 9% of a core, plus `flatpak-system-helper` and AppStream downloads), and IBus started a daemon, the kimpanel panel and its GTK extension that nothing can use: KWin hands text input to the one input method it starts (`ft-textinput`), and the session drops `QT_IM_MODULE`, `GTK_IM_MODULE` and `XMODIFIERS`. The session hides both for this desktop only, with `Hidden=true` copies in its own autostart folder. The geoclue demo agent stays: it's what answers apps' location requests to Geoclue outside GNOME, and it costs nothing while idle. Orca's entry only starts in GNOME-family desktops.
Plasma 6.2.5 keeps each panel on a screen number (`lastScreen` in `plasma-org.kde.plasma.desktop-appletsrc`), and the numbers rank the enabled outputs by priority, so 0 is the primary screen. A panel whose number is past the screen count gets no view, and Plasma never moves it: the remap it runs at every start only moves a panel whose number has no desktop, and this desktop keeps a desktop for every output it has seen, spares included. So the taskbar was lost when the number of screens went down, and once it was found saved on a spare output, number 8 of a desktop with three screens ([#18](https://github.com/DeeJanuz/frametop/issues/18)). Before Plasma starts, the session runs `session/fix-panels.py`, which moves any panel numbered past the screen count, with its system tray's containment, to screen 0, keeping its widgets and settings. A panel stays put when screen 0 already has one on that edge, and comes back by itself if the screens do. The file is backed up to `<file>.ft-bak` first. Plasma's scripting can't do this while it runs (`panel.screen` is read-only in 6.2.5), so a lost taskbar comes back at the desktop's next start. `scripts/doctor.sh` and `scripts/report.sh` list the panels and their screens.
Plasma 6.2.5 keeps each panel on a screen number (`lastScreen` in `plasma-org.kde.plasma.desktop-appletsrc`), and the numbers rank the enabled outputs by priority, so 0 is the primary screen. A panel whose number is past the screen count gets no view, and Plasma never moves it: the remap it runs at every start only moves a panel whose number has no desktop, and this desktop keeps a desktop for every output it has seen, spares included. So the taskbar was lost when the number of screens went down, and once it was found saved on a spare output, number 8 of a desktop with three screens ([#18](https://github.com/DeeJanuz/frametop/issues/18)). Before Plasma starts, the session runs `session/fix-panels.py`, which moves any panel numbered past the screen count, with its system tray's containment, to screen 0, keeping its widgets and settings. A panel stays put when screen 0 already has one on that edge, and comes back by itself if the screens do. Before each repair the file is backed up to `<file>.ft-bak.last`. `<file>.ft-bak` keeps it as it was before the first repair and is never overwritten. The repair writes over a moved panel's old screen number, so the backups are the only record of it, and `.ft-bak.last` also keeps everything changed since the first repair. Plasma's scripting can't do this while it runs (`panel.screen` is read-only in 6.2.5), so a lost taskbar comes back at the desktop's next start. `scripts/doctor.sh` and `scripts/report.sh` list the panels and their screens.
Remote desktop is a chain (krdp, then FreeRDP inside Xvnc) because nothing on SteamOS serves KWin over VNC directly. Kept connected all the time, it cost about a core with nobody watching: krdpserver 55 to 78% (it encodes H.264 in software with openh264: VA-API finds no driver for the Frame's GPU in the container), FreeRDP 16 to 27%, Xvnc 6 to 11%, and the bridge's layout check every 5 seconds another 4%. krdp creates its screencast session per RDP connection and drops it when the connection closes (`SessionController::onNewConnection` in krdp 6.7), so an idle krdpserver costs nothing and can stay up; only the RDP connection has to go. The bridge connects FreeRDP when a VNC client appears and disconnects 45 seconds after the last one leaves. Xvnc has no hook for its clients, so the bridge counts established connections to its port with `ss`, woken early by Xvnc's log output; looking with `ss` once a second cost about 0.9% of a core in bash, against about 0.1% this way. `Xvnc -inetd` from a systemd socket would start a server per connection and lose sharing between viewers. The layout check (`ft-layout remote-view`, which scans `/proc` for plasmashell and runs `kscreen-doctor -j`) now runs only while FreeRDP runs, and then only after `kwinoutputconfig.json` or `frametop-layout.json` changes, with one check a minute in case a change touched neither.
@@ -226,7 +229,7 @@ Hiding the screens during a game kept them out of view, but Frametop kept using
On the Frame, SteamVR is part of the OS image (`/opt/steamvr`, the `deckard-steamvr-rel` package), next to KWin, gamescope, and the kernel, so every SteamOS update can bring a new SteamVR too. Frametop survives updates: it lives in the home folder and the `dev` container, the Bluetooth fixes are in `/etc`, which SteamOS keeps across updates, and nothing goes into `/usr`. What an update can break is what Frametop uses from the image. The public OpenVR API is versioned and stays put. The rest is less certain: `IVRIPCResourceManagerClient`, which is newer than the header SteamVR ships; the text `vrcmd --overlays` prints; the eye tracker's shared memory layout; XRService's camera buffers; KWin's nested backend; and behavior Frametop works around, such as the SteamVR Settings page that `ComputeOverlayIntersection` can't find or the scale KWin's nested backend doesn't undo.
`scripts/update-check.py`, which `scripts/doctor.sh` runs, checks what it can directly: that SteamVR still serves every OpenVR interface version the installed programs were built against (read from the binaries), that `vrcmd`'s format still parses, that the eye tracker's sample timestamp is still at the offset ft-gaze reads, and the host files, services, sockets, and driver registration. Behavior can't be checked without someone in the headset, so it records the versions of the packages that matter once things work (`--mark-good`), and after an update names what changed and what to try by hand.
`scripts/update-check.py`, which `scripts/doctor.sh` runs, checks what it can directly: that SteamVR still serves every OpenVR interface version the installed programs were built against (read from the binaries), that `vrcmd`'s format still parses, that the eye tracker's shared memory still has a layout ft-gaze knows (stable's, or the 0.4.x beta's, with every field from the timestamp on 5 bytes later), by the same test ft-gaze uses to pick one, and the host files, services, sockets, and driver registration. Behavior can't be checked without someone in the headset, so it records the versions of the packages that matter once things work (`--mark-good`), and after an update names what changed and what to try by hand.
## Approaches we dropped
+1 -1
View File
@@ -85,7 +85,7 @@ ft-screens creates a `screen` for each toplevel in the order they appear, and in
runs commands ◀─ long poll ─ spare outputs (kscreen-doctor) ◀─ @frametop_float ─ lasers, catcher
```
- **KWin script `frametop-float`** (`float/frametop-float.js`). ft-floatd loads it into the desktop's KWin over D-Bus (`org.kde.kwin.Scripting`). A script keeps working across KWin updates. A C++ effect would have to match the host's exact KWin build, and the build container is Fedora, not SteamOS. The script watches windows (`windowAdded`/`windowRemoved`, `frameGeometryChanged`, `outputChanged`, `interactiveMoveResizeStarted`/`Finished`, `fullScreenChanged`, `maximizedChanged`, `minimizedChanged`, `keepBelowChanged`, `windowActivated`) and the outputs (`screensChanged`). It runs commands: move a window to an output, set its geometry, put it on all virtual desktops, and restore it. It adds "Float in VR" ("Back to Desktop" on a floating window) to the window menu (`registerUserActionsMenu`). It registers no shortcut: the float key belongs to the input relay. KWin scripts can call D-Bus but can't serve it, so commands come back through a long poll. The script calls ft-floatd's `NextCommand`, which answers when a command is ready, and then the script calls it again. It also keeps KWin's placement memory from moving windows (see design.md).
- **KWin script `frametop-float`** (`float/frametop-float.js`). ft-floatd loads it into the desktop's KWin over D-Bus (`org.kde.kwin.Scripting`). A script keeps working across KWin updates. A C++ effect would have to match the host's exact KWin build, and the build container is Fedora, not SteamOS. The script watches windows (`windowAdded`/`windowRemoved`, `frameGeometryChanged`, `outputChanged`, `interactiveMoveResizeStarted`/`Finished`, `fullScreenChanged`, `maximizedChanged`, `minimizedChanged`, `keepBelowChanged`, `windowActivated`) and the outputs (`screensChanged`). It runs commands: move a window to an output, set its geometry, put it on all virtual desktops, and restore it. It adds "Float in VR" ("Back to Desktop" on a floating window) to the window menu (`registerUserActionsMenu`). It registers no shortcut: the float key belongs to the input relay. KWin scripts can call D-Bus but can't serve it, so commands come back through a long poll. The script calls ft-floatd's `NextCommand`, which answers when a command is ready, and then the script calls it again, or after 30 seconds without an answer. It also keeps KWin's placement memory from moving windows (see design.md).
- **ft-floatd** (`float/ft-floatd`, Python). The host has dbus-python and PyGObject. It runs inside the desktop's Plasma session, started from its autostart. It owns `org.frametop.Float` on the session's private bus, and it keeps the table of which window is on which output and panel. It enables and disables spare outputs and sets their scale and position with `kscreen-doctor`, and their size through ft-screens. It tells ft-screens where each floating window goes and tells the script which window goes where. It launches apps floating, opens profiles' apps, and remembers each app's placement and scale, keyed by desktop file name. Commands come in on `@frametop_float`, from `ft-float`, the input relay, and ft-screens.
- **ft-screens.** A spare output's panel is a floating window's. Floating panels get the same bar, curve, roll, resize tab, and wrist and head pins as screens, plus dock and close buttons left of the bar. Other parts: the catcher, popup and dialog overlays, and carrying a panel during a KWin move. `MAX_SCREENS` (screens and spares together) is 24. Commands arrive on `@ft_screens`. Events go out to `@frametop_float` from an unbound socket, the same way ft-screens talks to the input relay.
- **Session script.** Adds `FLOAT_SLOTS` to KWin's output count, starts ft-floatd from the desktop's autostart, installs Frametop's window decoration and chooses it in the session's `kwinrc`, and writes the Launch as Standalone copies of the apps' desktop files.
+3 -1
View File
@@ -17,9 +17,11 @@ The input relay takes the volume keys from every device that has them, so gamesc
ft-screens drops keys while no screen has focus or the SteamVR dashboard is open, but always lets through the release of a key the desktop saw pressed, so a modifier held as the dashboard opens doesn't stay down.
- **A key whose release never arrives stays held in the desktop until the relay clears it, within about a second.** KWin repeats held keys itself, so a stuck letter repeats and a stuck modifier changes every later key (Ctrl+Alt held turns T into Konsole). The relay remembers which keys it told the desktop went down, and once a second it releases any that no keyboard holds (`reconcile_desktop_keys`, which asks the kernel with `EVIOCGKEY`). Pressing and releasing the key again also clears it.
- **A keyboard that disconnects mid-press, or a relay restart with a key down, is how it happens.** The once-a-second check catches the first. A relay that starts doesn't know what an earlier one left down, so it releases the modifiers on the desktop; another key left down that way stays until it's pressed and released again.
- **A keyboard that disconnects mid-press, or a relay restart with a key down, is how it happens.** The once-a-second check catches the first. A relay that starts doesn't know what an earlier one left down, so it releases the modifiers and mouse buttons on the desktop; another key left down that way stays until it's pressed and released again.
- **To see where a key went,** run `scripts/keys-report.py` and reproduce the problem while it records. It logs the modifiers, Tab, and Esc (no other keys) as the relay reads them and as its virtual keyboard sends them on, with the device roles and grabs, which programs have each keyboard open, and the relay's and desktop's logs.
- **Switching where typing goes waits for keys to come up.** The relay changes a keyboard's grab only while none of its keys are down, so a press and its release go to the same side. A key held for a long time delays the switch until it's let go.
- **Mouse buttons passed through as keys follow the pointer, not typing.** In pointer mode, a mouse button set to Pass through as key (a side button for Back) goes to the screen under the pointer, only while there is one and Frametop isn't paused. Its release always goes through, and ft-screens releases it itself when the pointer leaves the screens, its screen hides or closes, or Frametop pauses; the real release that comes later is dropped. So a drag held with it ends as the laser leaves a screen, unlike a laser's own click, which KWin keeps until it comes up. wlroots counts presses per button, so a release lost on the way would leave that button dead in the desktop, the lasers' clicks included, until ft-screens restarts. The button also goes to the virtual mouse, which gamescope reads, so the app Steam has focused may see it too.
- **Media keys reach both sides.** A keyboard's media keys come from its Consumer Control node, which has volume keys too, so the relay remaps that node rather than grabbing it. Its other keys reach gamescope as well as the desktop, even while typing goes to the desktop and the keyboard itself is grabbed: Play/Pause on the desktop can pause something in Steam too. The headset's own buttons don't go to the desktop.
## Typing and grabbed keyboards
+12 -9
View File
@@ -6,15 +6,17 @@ How each part of Frametop works, where its settings live, and the commands for r
From the headset, open Launch a program → Desktop. The installer replaces that launcher entry with Frametop's (`~/.local/share/applications/deckard-nested-desktop.desktop`), and `desktops.sh uninstall` gives the stock single-screen desktop back.
The installer also adds Native Desktop to the same list: a copy of SteamOS's entry for its own desktop (`~/.local/share/applications/native-deckard-nested-desktop.desktop`), skipped when SteamOS has no such entry. The copy is made at install time, so `scripts/update-check.py` warns when SteamOS's entry changes, and `desktops.sh install` refreshes it. In Native Desktop, typing goes to Steam's side, so the input relay doesn't grab keyboards there, and a Meta tap runs Frametop's Meta action (by default, the Steam menu) as well as opening Plasma's launcher.
From a terminal, on the Frame or from a PC over SSH:
```
desktops.sh install # the launcher's Desktop entry starts Frametop
desktops.sh uninstall # back to the stock SteamOS desktop
desktops.sh install # the launcher's Desktop entry starts Frametop; Native Desktop is the stock one
desktops.sh uninstall # back to the stock SteamOS desktop, as Desktop
desktops.sh start | stop | restart | status | log [lines]
```
`session/frametop-session.sh` runs the desktop. It starts ft-screens in the `dev` container (log: `/tmp/frametop-screens.log`), then KWin and Plasma on the host inside it. Only one desktop runs at a time. `desktops.sh start` runs it in its own systemd unit, `frametop-desktop`. It keeps its Plasma config in `~/.config/frametop`, separate from the stock desktop's.
`session/frametop-session.sh` runs the desktop. It starts ft-screens in the `dev` container (log: `/tmp/frametop-screens.log`), then KWin and Plasma on the host inside it. Only one Frametop desktop runs at a time. Its check doesn't look for Native Desktop, and running both at once is untested. `desktops.sh start` runs it in its own systemd unit, `frametop-desktop`. It keeps its Plasma config in `~/.config/frametop`, separate from the stock desktop's.
When the VR launcher starts the desktop, it inherits the Steam client's environment. The session script drops the client's runtime from it (`LD_LIBRARY_PATH`, the `STEAM_*` settings, and the Steam overlay's Vulkan layer), so apps in the desktop use the system's libraries, including its video codecs, just as they would after a normal login.
@@ -66,7 +68,7 @@ In the last three modes the hotkey shows the screens anyway. A screen can also b
- During VR games, the Always mode hides the screens unless the dashboard is open (the default), or leaves them up.
- Controllers on the screens. Visible screens can keep SteamVR's laser mouse on, so controllers work them with the dashboard closed, but that also takes the controllers away from a game. By default this is off while a VR game runs, and the 3D mouse or the dashboard works the screens. Pointing a controller at a screen, a floating window, or the keyboard still turns its laser on, like SteamVR's own floating windows, and pointing away gives the game the controllers back. The other choices are always on, or only with the dashboard open, which also suits flatscreen games since they aren't scene apps.
Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay, from pass-through keyboards and any key a pointer device passes through. Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing.
Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay: from pass-through keyboards, a keyboard's media keys (its Consumer Control node), and any key a pointer device passes through. A mouse button passed through as a key in pointer mode (a side button for Back) goes to the screen the pointer is on instead, wherever typing goes, and only while the pointer is on a screen that shows and Frametop isn't paused. Its release always goes through, and ft-screens releases it itself when the pointer leaves the screens, its screen hides, or Frametop pauses (`scripts/test-relay-buttons.sh` checks this offline). Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing.
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.
@@ -101,7 +103,7 @@ 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 and modifier taps, against fake devices (safe next to the live relay)
input/test/keys-test.py # key combinations, modifier taps, and what reaches the desktop, against fake devices (safe next to the live relay)
steam/ft-steam menu # what Open Steam menu does; ft-steam check: Steam's UI still has the calls
```
@@ -175,7 +177,7 @@ layout/ft-layout hide N|all # hide a screen on its own, whatever the visibility
display-settings/install.sh # menu entries and the Meta+Shift+R and Meta+Shift+H shortcuts
```
The layout is stored relative to your head when it's applied. `/tmp/frametop-layout.log` has the run from the last desktop start.
The layout is stored relative to your head when it's applied. `/run/user/<uid>/frametop-layout.log`, in the host's runtime directory (not the nested desktop's `/run/user/<uid>/frametop`), has the run from the last desktop start and ft-screens' layout runs after it.
## Floating windows
@@ -223,7 +225,7 @@ Paused, Frametop leaves the headset's CPU and GPU to a VR game. The input relay
- The gaze service stops (`frametop-gaze`: ft-gazed, ft-gaze, our own eye tracker, the gaze panel), so nothing reads SteamVR's eye tracking. Our frame grabber, the root service `ft-eyegrab`, goes idle by itself 3 seconds after our eye tracker stops asking it for frames.
- Hand tracking stops if it runs (`frametop-camd`, `frametop-hands`).
- The desktop, as the Game optimization page of Frametop Input Settings says (`pause_desktop`): hidden (the default) or closed. Hidden, ft-screens hides every screen and floating window whatever the visibility mode, the hotkey, or the dashboard says, and gives KWin a frame callback once a second instead of every display frame. KWin draws a screen only after its frame callback, and its apps wait for theirs, so the desktop hardly draws, but its windows stay open. Remote desktop stops if it runs (`session/remote-ctl.sh`). Closed, `desktops.sh stop` closes the desktop and its windows, and resuming starts it again (about 12 seconds), in its start profile if it has one.
- The relay lets go of the 3D mouse and feeds pointer devices to its virtual mouse and keyboard, as with `POINTER=0`. Typing goes to Steam. Mapped buttons and key combinations do nothing but pausing, the Steam menu, and commands; a key combination that does nothing is typed as usual.
- The relay lets go of the 3D mouse, releasing any click still held on it (a mouse button, a mapped controller button, or a key combination), and feeds pointer devices to its virtual mouse and keyboard, as with `POINTER=0`. Typing goes to Steam. Mapped buttons and key combinations do nothing but pausing, the Steam menu, and commands; a key combination that does nothing is typed as usual.
Resuming starts again only what pausing stopped, and plays a second sound. The pointer helper and ft-powerd keep running: they cost little, the helper is what says a game started, and stopping it would leave its virtual controller connected with its last pose.
@@ -239,6 +241,7 @@ input/ft-pause on | off | toggle # pause or resume
input/ft-pause status # the state as JSON (the relay's "pause ?")
input/vrws.py 10 # the controllers' buttons from vrserver's web socket, for 10 s
input/test/pause-test.py # the gesture and the automatic pause, offline
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.
@@ -260,10 +263,10 @@ Deferred: it costs a lot of the headset's CPU and needs more work, so `install.s
Your hands show over the screens: where a tracked hand is between an eye and a screen, ft-screens lets that eye see the room through the screen. The same tracker detects pinches and grips, and with `POINTER_HANDS=1` in `~/.config/frametop.conf` they work the pointer. In gaze mode a pinch clicks where you look when it opens; hold it and move the hand to correct the pointer first. Without gaze mode a pinch is a press like the mouse's button, so a held pinch drags. A grip (closing the hand) presses and drags. To install it: `hands/run.sh install`.
- `ft-camd` borrows XRService's camera buffers and publishes the four IR tracking cameras to `/run/user/UID/frametop-hands/cam-ring`. It runs on the host as `frametop-camd.service`, with file capabilities that `hands/run.sh install` sets through sudo, and it drops them once set up. A rebuild clears them: `hands/run.sh caps`.
- `ft-camd` borrows XRService's camera buffers and publishes the four IR tracking cameras to `/run/user/UID/frametop-hands/cam-ring`. It runs on the host as `frametop-camd.service`, with file capabilities that `hands/run.sh install` sets through sudo, and it drops them once set up. A rebuild clears them: `hands/run.sh caps`. The Hand Recorder's installer (`hands/rec/install.sh`) sets them the same way. `hands/run.sh uncaps` takes them back while neither the services nor the Hand Recorder is installed. `hands/run.sh uninstall` and `hands/rec/install.sh uninstall` run it after removing their own part, and `uninstall.sh` always takes them back.
- `ft-hands` runs in the `dev` container as `frametop-hands.service`. It finds and triangulates the hands, and publishes `hands` (read by ft-screens' cutouts) and `gestures` (pinches and grips, read by the pointer helper) next to the ring.
- The install leaves both off, and they don't start with SteamVR. `ft-handsctl on` starts them while SteamVR runs, and `ft-handsctl off` stops them; they also stop with SteamVR. The install links `ft-handsctl` into `~/.local/bin`. `ft-handsctl status` and `ft-handsctl log` (or `hands/run.sh status` and `log`) show how they're doing, `ft-handsctl cutouts on|off` turns just the cutouts off, and `ft-handsctl gestures` shows pinches and grips live.
- Settings in `~/.config/frametop.conf`: `HANDS_SWAP_SIDES` (after some SteamVR restarts the side cameras' names come out swapped, and hands land beside the holes; `hands/tools/check_sides.py --ring` tells), `HANDS_CPUS`, the cameras it tracks with (`HANDS_CAMERAS`, `HANDS_BRIGHT`, `HANDS_BRIGHT_ON`, `HANDS_BRIGHT_OFF`, `HANDS_COLOR_LEFT`, `HANDS_COLOR_CROP`), and the pointer helper's `POINTER_HANDS`, `POINTER_PINCH_GAIN`, `POINTER_PINCH_DEADZONE`, `POINTER_GRIP_GAIN`, `POINTER_GRIP_BELOW`, and `POINTER_PINCH_TYPING`. The example config explains each.
- Settings in `~/.config/frametop.conf`: `HANDS_SWAP_SIDES` (`auto`, the default: ft-hands tells from the hands when some SteamVR restart has swapped the side cameras' names, and fixes them; `0` or `1` force them, and `hands/tools/check_sides.py --ring` tells which is right), `HANDS_CPUS`, the cameras it tracks with (`HANDS_CAMERAS`, `HANDS_BRIGHT`, `HANDS_BRIGHT_ON`, `HANDS_BRIGHT_OFF`, `HANDS_COLOR_LEFT`, `HANDS_COLOR_CROP`), and the pointer helper's `POINTER_HANDS`, `POINTER_PINCH_GAIN`, `POINTER_PINCH_DEADZONE`, `POINTER_GRIP_GAIN`, `POINTER_GRIP_BELOW`, and `POINTER_PINCH_TYPING`. The example config explains each.
Details, options, and the recording and replay tools are in [hands/README.md](../hands/README.md).
+37 -2
View File
@@ -411,11 +411,46 @@ function run(c) {
}
}
// The long poll has one failure mode: a reply that never comes. KWin's callDBus never calls
// back when a call fails (an error reply, ft-floatd gone, or its 25 s D-Bus timeout, which
// ft-floatd blocked that long hits); it only logs "Received D-Bus message is error". Then
// polling stays true and the script stops hearing commands until KWin reloads it. A watchdog
// re-arms the poll. Its period must stay above that 25 s timeout, whatever ft-floatd's
// POLL_SECONDS is: by then the call it gives up on has ended. The serial is for a reply that
// still comes later (KWin stalled with the reply queued): it runs its commands (ft-floatd
// sends each one once) but doesn't poll again. Two polls would answer each other for good,
// since ft-floatd answers a waiting poll empty when the next one comes.
// When the watchdog fires again and again, ft-floatd is gone, and only starting it again
// helps (it reloads the script then). Each failed call is a line in KWin's log, so the
// script says so once and waits twice as long each time, up to 5 minutes. A reply starts
// over at 30 s, so a lost reply is still polled again after 30 s.
const POLL_WAIT = 30000, POLL_WAIT_MAX = 300000;
const pollWatchdog = new QTimer();
pollWatchdog.singleShot = true;
pollWatchdog.interval = POLL_WAIT;
let pollLost = false; // the watchdog fired, and no reply since
pollWatchdog.timeout.connect(() => {
if (!pollLost) print("frametop-float: NextCommand didn't answer, polling again");
pollLost = true;
pollWatchdog.interval = Math.min(pollWatchdog.interval * 2, POLL_WAIT_MAX);
polling = false;
poll();
});
let pollSerial = 0;
function poll() {
if (polling) return;
polling = true;
const serial = ++pollSerial;
pollWatchdog.start();
callDBus(SERVICE, PATH, IFACE, "NextCommand", reply => {
polling = false;
const current = serial === pollSerial; // not a call the watchdog gave up on
if (current) {
pollWatchdog.stop();
pollWatchdog.interval = POLL_WAIT;
pollLost = false;
polling = false;
}
if (reply) {
try {
JSON.parse(reply).forEach(run);
@@ -423,7 +458,7 @@ function poll() {
print("frametop-float: bad command " + reply + ": " + e);
}
}
poll();
if (current) poll();
});
}
+126 -16
View File
@@ -98,6 +98,10 @@ double NowRaw() {
}
// --- eye-server.mmap (packed, unaligned: read with memcpy) ---
// The SteamOS 0.4.x beta (SteamVR 2.18.2) moved every field from the timestamp on by 5
// bytes (measured 2026-10-04 with the ftdiag scan: timestamp 0x157 -> 0x15c, the vectors
// moved with it; the counter at 0x38 kept its place). Which layout is live is detected at
// runtime (EyeFile::Detect), so one binary serves both generations.
constexpr size_t kCounter = 0x38; // u32, one per sample
constexpr size_t kTime = 0x157; // f64, CLOCK_MONOTONIC_RAW seconds
constexpr size_t kLeft1 = 0x15f, kRight1 = 0x16b; // set 1: unit vectors, head space
@@ -110,11 +114,19 @@ constexpr size_t kVar1 = 0x177, kVar2 = 0x1b3;
// The measurements the filter is fed: left x, y, right x, y, then the variance of each (left
// x, y, right x, y). An eye's pair stops changing while the tracker can't see it.
constexpr size_t kMeas = 0x1d3;
constexpr size_t kNeed = 0x1f3;
constexpr size_t kNeed = 0x1f3 + 5; // enough for either layout
constexpr size_t kShifts[] = {0, 5}; // the layouts EyeFile::Detect knows: stable, 0.4.x beta
struct EyeFile {
// Everything from the timestamp on is read at base + shift: 0 on stable, 5 on the
// 0.4.x beta (see the constants above). known once Detect has seen that layout's
// timestamp tick; before that, reading would yield garbage that still passes
// ReadSample's check.
size_t shift = 0;
bool known = false;
const uint8_t *p = nullptr;
size_t size = 0;
size_t At(size_t base) const { return base + shift; }
bool Open() {
const int fd = open("/dev/shm/eye-server.mmap", O_RDONLY | O_CLOEXEC);
if (fd < 0) return false;
@@ -130,6 +142,9 @@ struct EyeFile {
size = st.st_size;
return true;
}
// The sample counter, or 0 without the mapping: the one read the loop makes whether or
// not the file was there (Get, V and ReadSample need it).
uint32_t Counter() const { return p ? Get<uint32_t>(kCounter) : 0; }
template <class T> T Get(size_t off) const {
T v;
std::memcpy(&v, p + off, sizeof v);
@@ -140,6 +155,53 @@ struct EyeFile {
std::memcpy(f, p + off, sizeof f);
return {f[0], f[1], f[2]};
}
// A live timestamp is near the clock it comes from (its samples are 17 ms or so old).
static bool TimePlausible(double t, double now) { return t > now - 2.0 && t <= now + 2.0; }
// The eye directions are unit vectors, so their length is a second, independent check
// next to the timestamp: a mere coincidence in one field does not confirm a layout.
static bool UnitVec(Vec3 v) {
const float n = v.x * v.x + v.y * v.y + v.z * v.z;
return n > 0.81f && n < 1.21f; // |v| within 0.9 .. 1.1
}
bool Fits(size_t layout, double now) const {
return TimePlausible(Get<double>(kTime + layout), now) && UnitVec(V(kLeft1 + layout)) &&
UnitVec(V(kRight1 + layout));
}
// Which layout is live, a step per pass of the loop, so it never stalls it. A layout
// fits when its timestamp is near the clock and its two set-1 directions are unit
// vectors; it's taken once its timestamp has moved on too, within kConfirm. That allows
// for 2 samples a second: the eye server writes 72 or 90 (15 were seen on the beta).
// kWaiting: call again on the next pass. kNone: no layout fits (a server that stopped,
// SteamVR's tracker warming up, or a layout we don't know), so the mmap stays unused;
// try again later. One detection per run is enough: the layout can't change under a
// running ft-gaze, since SteamVR starts the eye server that writes the file, and the
// gaze service stops and starts with SteamVR.
enum Detection { kWaiting, kFound, kNone };
static constexpr double kConfirm = 0.5;
static constexpr size_t kLayouts = sizeof kShifts / sizeof kShifts[0];
Detection Detect(double now) {
if (!fit_) {
for (size_t i = 0; i < kLayouts; ++i)
if (Fits(kShifts[i], now)) fit_ |= 1u << i, t0_[i] = Get<double>(kTime + kShifts[i]);
if (!fit_) return kNone;
since_ = now;
return kWaiting;
}
for (size_t i = 0; i < kLayouts; ++i)
if ((fit_ >> i & 1) && Get<double>(kTime + kShifts[i]) > t0_[i] && Fits(kShifts[i], now)) {
fit_ = 0, shift = kShifts[i], known = true;
return kFound;
}
if (now - since_ <= kConfirm) return kWaiting;
fit_ = 0;
return kNone;
}
bool Detecting() const { return fit_ != 0; }
// Detect's state: the layouts that fit at since_ (a bit each), and their timestamps then.
unsigned fit_ = 0;
double t0_[kLayouts] = {}, since_ = 0;
};
struct EyeSample {
@@ -151,20 +213,22 @@ struct EyeSample {
};
// A consistent copy: the writer has no seqlock we can use, so read until the counter and
// timestamp are the same before and after.
// timestamp are the same before and after. Only call this once the layout is known
// (EyeFile::known): on an unknown layout these reads still pass the consistency check,
// but yield garbage.
bool ReadSample(const EyeFile &f, EyeSample &s) {
for (int attempt = 0; attempt < 4; ++attempt) {
const uint32_t n0 = f.Get<uint32_t>(kCounter);
const double t0 = f.Get<double>(kTime);
const double t0 = f.Get<double>(f.At(kTime));
std::atomic_thread_fence(std::memory_order_acquire);
s.left1 = f.V(kLeft1), s.right1 = f.V(kRight1), s.fix1 = f.V(kFix1);
s.left2 = f.V(kLeft2), s.right2 = f.V(kRight2);
std::memcpy(s.open, f.p + kOpen, sizeof s.open);
std::memcpy(s.var1, f.p + kVar1, sizeof s.var1);
std::memcpy(s.var2, f.p + kVar2, sizeof s.var2);
std::memcpy(s.meas, f.p + kMeas, sizeof s.meas);
s.left1 = f.V(f.At(kLeft1)), s.right1 = f.V(f.At(kRight1)), s.fix1 = f.V(f.At(kFix1));
s.left2 = f.V(f.At(kLeft2)), s.right2 = f.V(f.At(kRight2));
std::memcpy(s.open, f.p + f.At(kOpen), sizeof s.open);
std::memcpy(s.var1, f.p + f.At(kVar1), sizeof s.var1);
std::memcpy(s.var2, f.p + f.At(kVar2), sizeof s.var2);
std::memcpy(s.meas, f.p + f.At(kMeas), sizeof s.meas);
std::atomic_thread_fence(std::memory_order_acquire);
if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(kTime) == t0) {
if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(f.At(kTime)) == t0) {
s.n = n0, s.t = t0;
return true;
}
@@ -525,7 +589,7 @@ int main(int argc, char **argv) {
std::fprintf(stderr, "ft-gaze: action manifest %s: error %d\n", manifest.c_str(), int(me));
EyeFile eyes;
const bool haveMmap = eyes.Open();
bool haveMmap = eyes.Open();
std::fprintf(stderr, "ft-gaze: eye-server.mmap %s\n", haveMmap ? "open" : "not available");
OwnFile ownFile;
@@ -544,6 +608,19 @@ int main(int argc, char **argv) {
// over the dashboard. Games are told apart the way ft-screens does it, by the scene app.
bool inGame = false;
double nextGameCheck = 0;
// The mmap's layout (EyeFile::Detect) is looked for while it isn't known and the eye
// server writes: the counter (0x38 in both layouts) ticks once per sample, so a silent
// server (headset off) costs nothing and logs nothing. Until a layout is known, SteamVR's
// tracker counts as unavailable. "Not recognized" waits until no layout has fitted for
// kUnknownAfter of writing, longer than SteamVR's tracker takes to warm up after the
// headset goes on (about 20 s), then goes to the journal at most once a minute; ft-gazed
// shows it on the Gaze page.
constexpr double kUnknownAfter = 30;
double nextLayoutCheck = 0, nextLayoutLog = 0, lastWrite = 0, missSince = 0;
uint32_t lastCounterSeen = eyes.Counter();
// SteamVR's eye tracker creates the mmap a second or two after SteamVR starts, so it can
// be missing when we start: look for it again every 2 s until it's there.
double nextOpen = NowRaw() + 2.0;
while (true) {
const double now = NowRaw();
@@ -559,11 +636,43 @@ int main(int argc, char **argv) {
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &hp, 1);
if (hp.bPoseIsValid) history.Add(now, hp.mDeviceToAbsoluteTracking);
// One line per new eye sample, or at 90 Hz without the mmap.
if (!haveMmap && now >= nextOpen) {
nextOpen = now + 2.0;
if ((haveMmap = eyes.Open())) {
std::fprintf(stderr, "ft-gaze: eye-server.mmap open\n");
lastCounterSeen = eyes.Counter();
}
}
// One line per new eye sample, or at 90 Hz without a usable mmap: none, or one whose
// layout isn't known (yet). Our own tracker needs nothing from the mmap, so it keeps
// going then; the mmap's sources print as {"ok":0} and "eye" as null.
EyeSample s;
bool fresh = false;
if (haveMmap && ReadSample(eyes, s) && s.n != lastN) fresh = true, lastN = s.n;
if (!haveMmap && now - lastEmit >= 1.0 / 90) fresh = true, s.t = now;
const uint32_t counter = eyes.Counter();
const bool writing = haveMmap && counter != lastCounterSeen;
lastCounterSeen = counter;
if (writing) {
if (now - lastWrite > 2.0) missSince = 0; // it was silent: start counting again
lastWrite = now;
}
if (haveMmap && !eyes.known && (eyes.Detecting() || (writing && now >= nextLayoutCheck))) {
const EyeFile::Detection d = eyes.Detect(now);
if (d == EyeFile::kFound) {
std::fprintf(stderr, "ft-gaze: eye-server.mmap layout: %s\n", eyes.shift ? "beta (+5)" : "stable");
} else if (d == EyeFile::kNone) {
nextLayoutCheck = now + 1.0;
if (!missSince) missSince = now;
if (now - missSince >= kUnknownAfter && now >= nextLayoutLog) {
nextLayoutLog = now + 60.0;
std::fprintf(stderr, "ft-gaze: eye-server.mmap has eye data, but its layout is not recognized: "
"SteamVR's eye tracking is unavailable\n");
}
}
}
const bool mmapOk = haveMmap && eyes.known;
if (mmapOk && ReadSample(eyes, s) && s.n != lastN) fresh = true, lastN = s.n;
if (!mmapOk && now - lastEmit >= 1.0 / 90) fresh = true, s.t = now;
if (fresh && hp.bPoseIsValid) {
lastEmit = now;
@@ -571,7 +680,7 @@ int main(int argc, char **argv) {
const auto list = screens.Get();
const vr::HmdMatrix34_t &headNow = hp.mDeviceToAbsoluteTracking;
vr::HmdMatrix34_t headThen = headNow;
if (haveMmap) history.At(s.t, headThen);
if (mmapOk) history.At(s.t, headThen);
// SteamVR's action: a room-space origin and fixation point, turned into the head
// frame so every source reports the same kind of angles.
@@ -599,7 +708,8 @@ int main(int argc, char **argv) {
}
std::string m1 = "{\"ok\":0}", m2 = m1, left = m1, right = m1, eye = "null";
if (haveMmap) {
// Only from a known layout: the unread sample's zeros would print an "unc" of 0 (eyes seen).
if (mmapOk) {
// lr: the angle between the two eyes' directions. It's a fraction of a degree
// normally; when the tracker loses one eye (or during a blink) it jumps.
auto lr = [](Vec3 l, Vec3 r) {
+8
View File
@@ -267,6 +267,7 @@ class Service:
self.checks = Checks(self, self.sel)
self.proc = None
self.proc_sources = None # what ft-gaze was last told to print
self.mmap_unknown = False # ft-gaze said SteamVR's eye-server.mmap has a layout it doesn't know
self.buf = b""
self.restart_at = 0.0
self.running = True
@@ -511,6 +512,7 @@ class Service:
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
self.sel.register(self.proc.stderr, selectors.EVENT_READ, "stderr")
self.buf = b""
self.mmap_unknown = False
log("ft-gaze started")
def stop_helper(self):
@@ -531,6 +533,7 @@ class Service:
except ProcessLookupError:
pass
self.proc = None
self.mmap_unknown = False
def eyes_wanted(self):
return (self.tracker == "own" and not self.override and self.awake) or time.monotonic() < self.eyes_until
@@ -613,6 +616,11 @@ class Service:
for line in data.decode("utf-8", "replace").splitlines():
if line.strip():
log(line)
# For the Gaze page (gazecheck's problem): ft-gaze can't read SteamVR's tracker.
if "layout is not recognized" in line:
self.mmap_unknown = True
elif "eye-server.mmap layout:" in line:
self.mmap_unknown = False
def judge_eyes(self, m1, down):
"""Which eyes (left, right) are closed, from SteamVR's openness (set 1); updates
+10 -1
View File
@@ -389,7 +389,16 @@ class Checks:
def problem(self):
"""Why gaze mode, on, can't follow your eyes yet, or None. Our tracker not having said
yet is None: Input Settings has its own line for our tracker."""
if not self.gaze_on or self.calibrated() is not False:
if not self.gaze_on:
return None
own = self.svc.kind == "own"
if self.svc.mmap_unknown and (not own or self.calibrated() is False):
# ft-gaze can't read SteamVR's tracker (EyeFile::Detect in ft-gaze.cpp): not its gaze,
# and not the eyes it sees, which our tracker's calibration waits for.
return ("SteamVR's eye data has a layout Frametop doesn't know (after a SteamOS update?), so "
+ ("the calibration can't open" if own else "SteamVR's eye tracker can't be used")
+ ". A newer Frametop may know it")
if self.calibrated() is not False:
return None
if self.check and self.check["kind"] == "full":
return "Not calibrated yet: the calibration is open in the headset"
+1 -1
View File
@@ -203,7 +203,7 @@ class GazeReader:
return
print(line, file=sys.stderr)
text = line.removeprefix("ft-gaze: ")
if not text.startswith(("action manifest", "eye-server.mmap open")):
if not text.startswith(("action manifest", "eye-server.mmap open", "eye-server.mmap layout:")):
self.last_err = text # worth repeating if ft-gaze stops (not the startup lines)
self.on_status(text)
stream.read_line_async(GLib.PRIORITY_DEFAULT, self.cancel, got)
+180
View File
@@ -0,0 +1,180 @@
// Offline test of ft-gaze's eye-server.mmap layout detection (EyeFile::Detect in
// gaze/ft-gaze.cpp) and of reading a sample at the layout it found, on a made-up file in
// memory: no SteamVR, no eye tracker. Detect takes the time as an argument, so the passes
// of ft-gaze's loop are played here with a made-up clock. mmap-layout-test.sh builds and
// runs it in the dev container.
#define main ft_gaze_main
#include "../ft-gaze.cpp"
#undef main
#include <cstdio>
#include <vector>
namespace {
int failures = 0;
#define CHECK(c) \
do { \
if (!(c)) std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #c), ++failures; \
} while (0)
// The file as the eye server writes it, one sample at a time, with every field from the
// timestamp on moved by `shift` (0 stable, 5 the 0.4.x beta).
struct File {
std::vector<uint8_t> bytes = std::vector<uint8_t>(324122); // eye-server.mmap's size
EyeFile eyes;
uint32_t n = 0;
File() { eyes.p = bytes.data(), eyes.size = bytes.size(); }
template <class T> void Put(size_t off, const T &v) { std::memcpy(bytes.data() + off, &v, sizeof v); }
void Sample(size_t shift, double t, bool leftLost = false) {
const float left[3] = {0.05f, 0.02f, -0.9985f}, right[3] = {-0.05f, 0.02f, -0.9985f}, none[3] = {};
Put(kCounter, ++n);
Put(kTime + shift, t);
Put(kLeft1 + shift, leftLost ? none : left);
Put(kRight1 + shift, right);
Put(kLeft2 + shift, left);
Put(kRight2 + shift, right);
const float open[2] = {0.8f, 0.7f}, var[6] = {1e-3f, 2e-3f, 3e-3f, 4e-3f, 5e-3f, 6e-3f};
const float meas[8] = {0.1f, 0.2f, 0.3f, 0.4f, 2e-5f, 3e-5f, 4e-5f, 5e-5f}, fix[3] = {0, 0.02f, -0.6f};
Put(kFix1 + shift, fix);
Put(kVar1 + shift, var);
Put(kVar2 + shift, var);
Put(kOpen + shift, open);
Put(kMeas + shift, meas);
}
};
constexpr double kStart = 5000; // the made-up CLOCK_MONOTONIC_RAW at the first pass
constexpr double kPass = 0.004; // ft-gaze's loop
constexpr double kAge = 0.017; // how old a sample is when it appears
// Samples every 1/hz s in `shift`'s layout, Detect called on every loop pass from the first
// sample on (as ft-gaze does once the counter moved), until it decides or `until` s pass.
// Returns the outcome and when (s after the first sample) in `at`.
EyeFile::Detection Run(File &f, size_t shift, double hz, double until, double &at) {
double next = kStart;
for (double now = kStart; now < kStart + until; now += kPass) {
if (now >= next) f.Sample(shift, now - kAge), next += 1 / hz;
const EyeFile::Detection d = f.eyes.Detect(now);
if (d != EyeFile::kWaiting) {
at = now - kStart;
return d;
}
}
at = until;
return EyeFile::kWaiting;
}
void Layouts() {
for (const size_t shift : {size_t(0), size_t(5)}) {
File f;
double at;
CHECK(Run(f, shift, 90, 2, at) == EyeFile::kFound);
CHECK(f.eyes.known && f.eyes.shift == shift);
CHECK(at <= 1 / 90.0 + kPass); // the next sample confirms it
}
}
void SlowWriters() {
// 15 a second, as seen on the beta: the next sample, 67 ms on, confirms it (PR #26's
// fixed 60 ms wait missed about 1 try in 10 there).
File beta;
double at;
CHECK(Run(beta, 5, 15, 2, at) == EyeFile::kFound && beta.eyes.shift == 5);
CHECK(at > 1 / 15.0 - kPass && at <= 1 / 15.0 + kPass);
// 3 a second still works (within kConfirm); 1 a second can't confirm in time.
File slow;
CHECK(Run(slow, 0, 3, 2, at) == EyeFile::kFound && slow.eyes.shift == 0);
File slower;
CHECK(Run(slower, 0, 1, 2, at) == EyeFile::kNone && !slower.eyes.known);
CHECK(at > EyeFile::kConfirm && at < EyeFile::kConfirm + 2 * kPass);
CHECK(!slower.eyes.Detecting()); // and the next try starts over
}
void Refused() {
// A layout we don't know: the same fields, moved by some other amount.
for (size_t shift = 1; shift <= 16; ++shift) {
if (shift == 5) continue;
File f;
double at;
const EyeFile::Detection d = Run(f, shift, 90, 1, at);
CHECK(d == EyeFile::kNone && !f.eyes.known);
if (d != EyeFile::kNone) std::printf(" (moved by %zu)\n", shift);
}
// A server that stopped: its last sample is 10 s old. Refused at once, no waiting.
File stale;
stale.Sample(0, kStart - 10);
CHECK(stale.eyes.Detect(kStart) == EyeFile::kNone && !stale.eyes.Detecting());
// One that stopped just now: its timestamp fits but never moves on.
File stopped;
stopped.Sample(0, kStart - kAge);
CHECK(stopped.eyes.Detect(kStart) == EyeFile::kWaiting);
EyeFile::Detection d = EyeFile::kWaiting;
double now = kStart;
while (d == EyeFile::kWaiting && now < kStart + 2) d = stopped.eyes.Detect(now += kPass);
CHECK(d == EyeFile::kNone && !stopped.eyes.known);
// A set-1 direction that isn't a unit vector (here zeros).
File warm;
warm.Sample(0, kStart - kAge, true);
CHECK(warm.eyes.Detect(kStart) == EyeFile::kNone);
// An empty file (the server never wrote).
File empty;
CHECK(empty.eyes.Detect(kStart) == EyeFile::kNone);
}
void TornWrite() {
// A pass that reads the timestamp mid-write (the counter already moved on, the top half
// of the new timestamp not written yet, so it's nowhere near the clock) keeps waiting:
// the next pass confirms it.
File f;
f.Sample(0, kStart - kAge);
CHECK(f.eyes.Detect(kStart) == EyeFile::kWaiting);
const double next = kStart + 0.011 - kAge;
std::memcpy(f.bytes.data() + kTime, &next, 4);
std::memset(f.bytes.data() + kTime + 4, 0, 4);
f.Put(kCounter, ++f.n);
CHECK(f.eyes.Detect(kStart + 0.012) == EyeFile::kWaiting);
f.Put(kTime, next);
CHECK(f.eyes.Detect(kStart + 0.016) == EyeFile::kFound && f.eyes.shift == 0);
}
void ReadsTheLayoutFound() {
// After detection on the beta, a sample comes from the moved fields.
File f;
double at;
CHECK(Run(f, 5, 90, 1, at) == EyeFile::kFound && f.eyes.shift == 5);
f.Sample(5, kStart + 1);
EyeSample s;
CHECK(ReadSample(f.eyes, s));
CHECK(s.n == f.n && s.t == kStart + 1);
CHECK(std::fabs(s.left1.x - 0.05) < 1e-6 && std::fabs(s.right2.x + 0.05) < 1e-6);
CHECK(std::fabs(s.fix1.z + 0.6) < 1e-6);
CHECK(s.open[0] == 0.8f && s.open[1] == 0.7f);
CHECK(s.var1[5] == 6e-3f && s.var2[0] == 1e-3f && s.meas[3] == 0.4f && s.meas[7] == 5e-5f);
}
void NoMapping() {
// ft-gaze without the file: the loop's one read of it gives 0, and touches no memory.
EyeFile none;
CHECK(none.Counter() == 0);
File f;
f.Sample(0, kStart);
CHECK(f.eyes.Counter() == f.n);
}
} // namespace
int main() {
Layouts();
SlowWriters();
Refused();
TornWrite();
ReadsTheLayoutFound();
NoMapping();
if (failures) {
std::printf("%d failed\n", failures);
return 1;
}
std::printf("all passed\n");
return 0;
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Offline test of ft-gaze's eye-server.mmap layout detection (gaze/test/mmap-layout-test.cpp):
# builds it against gaze/ft-gaze.cpp in the dev container and runs it there. Nothing reaches
# SteamVR or the eye tracker, so it's safe next to them. gaze/build.sh fetches the OpenVR
# header it needs, so run that once first.
#
# gaze/test/mmap-layout-test.sh
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
"$root/scripts/sync.sh" >/dev/null
exec "$root/scripts/frame.sh" -C gaze 'set -e
[ -f build/include/openvr.h ] || { echo "no build/include/openvr.h: run gaze/build.sh first" >&2; exit 1; }
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -Wno-missing-field-initializers -Ibuild/include -I../pointer/common \
-o build/mmap-layout-test test/mmap-layout-test.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api \
-Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread
build/mmap-layout-test'
+3
View File
@@ -111,6 +111,9 @@ reading only.
The file is 324,122 bytes. Only bytes 0x0-0x1f3 are used; the rest is zero. It's packed and
unaligned, so read it with memcpy. Offsets are also in `~/frametop/gaze/ft-gaze.cpp`.
On the SteamOS 0.4.x beta (SteamVR 2.18.2) every field from 0x157 on sits 5 bytes later, and the
counter stays at 0x38 (measured 2026-10-04, PR #26). The offsets below are stable's; ft-gaze detects
which layout is live.
| Offset | What |
| --- | --- |
+1 -1
View File
@@ -61,7 +61,7 @@ Run the second command after SteamVR has restarted, as in the install.
~/frametop/hands/rec/install.sh uninstall
```
This removes the menu entry. Your recordings stay in `~/.local/share/frametop/hands/contrib`; delete that folder to remove them. To remove Frametop as well, follow [Uninstall](https://github.com/DeeJanuz/frametop#uninstall) in the README.
This removes the menu entry. With your `sudo` password, it also takes back the camera broker's permission to read the cameras. If you also installed Frametop's live hand tracking, the camera broker keeps that permission, because live hand tracking still uses it. Your recordings stay in `~/.local/share/frametop/hands/contrib`; delete that folder to remove them. To remove Frametop as well, follow [Uninstall](https://github.com/DeeJanuz/frametop#uninstall) in the README.
## Help
+3 -2
View File
@@ -31,12 +31,13 @@ hands/run.sh restart # after changing a setting
hands/run.sh status
hands/run.sh log [lines]
hands/run.sh caps # after rebuilding ft-camd (a rebuild clears its capabilities)
hands/run.sh uninstall
hands/run.sh uncaps # take them back, unless the Hand Recorder or the services use them
hands/run.sh uninstall # the services, then uncaps
```
Settings in `~/.config/frametop.conf` (`FT_<name>` in the environment overrides them), read when ft-camd and ft-hands start:
- `HANDS_SWAP_SIDES=auto` (the default): ft-hands tells from the hands which side camera is which, and corrects ft-camd's names when they're backwards (see "Which camera is which" below). `1` forces them exchanged and `0` forces ft-camd's names; ft-hands still checks and logs a warning if the hands disagree.
- `HANDS_SWAP_SIDES=auto` (the default): ft-hands tells from the hands which side camera is which, and corrects ft-camd's names when they're backwards (see "Which camera is which" below). `1` forces them exchanged and `0` forces ft-camd's names; ft-hands still checks, and if the hands disagree it logs a warning and publishes the hands' answer as the truth (`sides.json`), so recordings are labelled right. The example config said `0` until 2026-10-05; `scripts/conf-migrate.sh` (run by `install.sh` and `hands/rec/install.sh`) turns that untouched line into `auto`.
- `HANDS_CPUS=5,6,7`: the CPUs the model threads run on (below).
- `HANDS_CAMERAS` (`auto`), `HANDS_BRIGHT` (`all`), `HANDS_BRIGHT_ON` (40), `HANDS_BRIGHT_OFF` (25): which cameras ft-hands tracks with, as `--cams`, `--bright`, `--bright-on` and `--bright-off` (see ft-hands). `HANDS_CAMERAS=mono` also keeps ft-camd off the colour cameras.
- `HANDS_COLOR_LEFT` (`color_video0`), `HANDS_COLOR_CROP` (`subtract`): how the colour module's calibration maps onto its images, as `--color-left` and `--color-crop`.
+2 -2
View File
@@ -29,7 +29,7 @@ Every part runs in the dev container, as ft-hands and Input Settings do. The hos
- **A tracking ft-hands** gives feedback through the hands file: which hands are seen, the palm's distance, the index tip. If none is running, the session starts `ft-hands --no-gestures --status 0` (unit `frametop-handrec-hands.service`). An ft-hands already running is used as it is.
- **A recording ft-hands** runs once per recording part: `ft-hands --record-only --record DIR --record-for SECONDS --record-hz 10 --status 0 --sides auto|0|1` (below, "Side cameras"). It runs as a plain child process of the session, ended with SIGTERM when the part ends. SIGTERM ends ft-hands' loop, and `Recorder` writes out its queue when it's destroyed. In step mode (below) a part is one step's countdown and hold, so a take has one part per step (about 40 in the hand poses); in auto mode a take is one part, plus one more after each pause. `--record-for` is only a safety net.
- Why a process per part rather than one kept alive and paused: measured in the dev container with `ft-ringplay`'s ring (2026-10-02), `ft-hands --record-only` writes its first set 16-27 ms after it starts and ends 4-6 ms after SIGTERM, so a new part costs nothing the 3 s countdown doesn't cover. Every reader already takes parts in order (`takes.py`, `validate.py` through the export's single stream, the labeller's `fhl_io.py`, numbering `sets-10.bin` after `sets-9.bin`), ft-hands needs no new control, and nothing is written while a step waits. Before the hold starts the session checks that the part has written a set (`Recorder.has_data`, up to 3 s more), so the hold is recorded from its first frame.
- **Side cameras.** ft-camd can name the two side cameras the wrong way round (hands/README.md, "Which camera is which"). The tracking ft-hands decides from the hands within about 2 s of them being in view (`HANDS_SWAP_SIDES=auto`, hands/track/sides.h) and publishes that in `/run/user/UID/frametop-hands/sides.json`. The session reads it (`sides.py`, `read_live`) and stores it in session.json `"sides"`. Each later part is recorded named right (`--sides 1` or `0`). Parts recorded before the decision use ft-camd's names (`--sides auto`; a record-only ft-hands can't tell), and readers rename them (`sides.py`). Each part's `names_swapped` goes into take.json `"parts"`. Without a tracking ft-hands nothing decides: `"swapped": null`, the names stay as recorded, and the maintainer's check (`hub_review check`, check_sides on a few sets per take) tells. `takes.py sides SESSION --set swapped|named` records a decision by hand.
- **Side cameras.** ft-camd can name the two side cameras the wrong way round (hands/README.md, "Which camera is which"). The tracking ft-hands decides from the hands within about 2 s of them being in view (`HANDS_SWAP_SIDES=auto`, hands/track/sides.h) and publishes that in `/run/user/UID/frametop-hands/sides.json`. With `HANDS_SWAP_SIDES` forced to `0` or `1`, the hands still decide what's published once they disagree (state `"forced, disagrees"`; `read_live` also corrects an ft-hands built before that). The session reads it (`sides.py`, `read_live`) and stores it in session.json `"sides"`. Each later part is recorded named right (`--sides 1` or `0`). Parts recorded before the decision use ft-camd's names (`--sides auto`; a record-only ft-hands can't tell), and readers rename them (`sides.py`). Each part's `names_swapped` goes into take.json `"parts"`. Without a tracking ft-hands nothing decides: `"swapped": null`, the names stay as recorded, and the maintainer's check (`hub_review check`, check_sides on a few sets per take) tells. `takes.py sides SESSION --set swapped|named` records a decision by hand.
- **ft-handpanel** runs as a child process with `--watch-stdin`. It shows the panel and logs poses during each take.
- **The headset button's reader** is a thread of the session (`ButtonReader`, below), not a process.
@@ -283,7 +283,7 @@ Before section 7: "Put on both controllers and tighten the straps". Before secti
- **Hands seen:** from the hands file. A hand counts as seen if its flags match the side and the file is fresh (publish within 0.3 s). Prompts with `hands` set show the `hands` chips. If an asked-for hand is lost for more than 1.5 s, the note says "I can't see your left hand: bring it into view".
- **Controller tracking:** in sections 8 and 9, `devices` is polled once a second. A result other than 200 for more than 1 s says "The left controller lost tracking: turn your palm slightly toward you". Each such stretch goes into `prompts.jsonl` as `feedback` with `"controller": {...}`.
- **Lighting, measured:** the checklist page measures the light when it opens, starting ft-camd (`frametop-handrec-camd.service`) if nothing runs it; the window stops it again on quit if it started it. The mean of every mono camera's `mean` and `dark_mean` from the ring (`hands/tools/ring.py` layout; struct only, no numpy) is compared with the person's earlier sessions. If it matches an earlier round's within 15%, the window says so before starting.
- **Lighting label:** "Measured by the cameras" is the default. `ambient_ir`, the mono cameras' mean `dark_mean` (the room's infrared), labels the round `daylight` from 6.0 and `indoor` below (`session.classify_lighting`). Lamps and LEDs give off hardly any infrared, so a dim room and a lit one read about the same (1.8 by one lamp, 2.2 in a lamp-lit room) and the cameras can't tell them apart; the person can pick dim, room or daylight instead (`source: "picked"`). The 6.0 threshold is a guess until a daylight round is measured.
- **Lighting label:** "Measured by the cameras" is the default. `ambient_ir`, the mono cameras' mean `dark_mean` (the room's infrared), labels the round `daylight` from 6.0 and `indoor` below (`session.classify_lighting`). Lamps and LEDs give off hardly any infrared, so a dim room and a lit one read about the same (1.8 by one lamp, 2.2 in a lamp-lit room) and the cameras can't tell them apart; the person can pick dim, room or daylight instead (`source: "picked"`). The 6.0 threshold was a guess, and the first daylight round (dataset PR #5, 2026-10-05, a room with big sunlit windows) read 2.38 and was labelled `indoor`: the windows are a small part of each picture. So the checklist now asks people to pick Daylight when sunlight comes in, and the maintainer corrects the label in review (`hub_review.py lighting`) when hands stand out far less than in lamp light (PR #5: hands a median 1.15x as bright as their surroundings, against 1.5-1.7x).
### Camera check
+7 -2
View File
@@ -7,7 +7,9 @@
# "Launch a program", which runs apps outside it; the standalone recorder is for that).
# It doesn't turn on Frametop's live hand tracking (that's hands/run.sh install, still deferred).
# Usage: hands/rec/install.sh install or update
# hands/rec/install.sh uninstall remove the menu entry (recordings stay where they are)
# hands/rec/install.sh uninstall remove the menu entry, and ft-camd's capabilities unless
# hand tracking's services use them (hands/run.sh uncaps).
# Recordings stay where they are.
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
. "$root/scripts/_env.sh"
@@ -23,6 +25,7 @@ case ${1:-install} in
"$root/hands/rec/build.sh"
echo "== 4/4 ft-camd's capabilities (asks for your password) and the menu entry"
"$root/hands/run.sh" caps
"$root/scripts/conf-migrate.sh" # HANDS_SWAP_SIDES=0, the old default, becomes auto
fill_template "$root/hands/rec/ft-handrec.desktop" |
on_frame "mkdir -p ~/.local/share/applications && cat > $entry"
echo
@@ -31,7 +34,9 @@ case ${1:-install} in
;;
uninstall)
on_frame "rm -f $entry"
echo "Removed the menu entry. Your recordings are still in ~/.local/share/frametop/hands/contrib:"
echo "Removed the menu entry."
"$root/hands/run.sh" uncaps # after the entry, which counts as a user of the capabilities
echo "Your recordings are still in ~/.local/share/frametop/hands/contrib:"
echo "delete that folder to remove them."
;;
*) echo "usage: $0 [install|uninstall]" >&2; exit 2 ;;
+2 -1
View File
@@ -362,7 +362,8 @@ Kirigami.ApplicationWindow {
wrapMode: Text.Wrap
opacity: 0.7
text: "Each round in a different light helps the most: dim, a normal room, daylight. The cameras "
+ "tell daylight from indoor light themselves; to say dim or a normal room, pick it here."
+ "can't tell daylight on their own (a sunlit room has measured as indoor light), so if "
+ "sunlight comes into the room, pick Daylight here; dim or a normal room the same way."
}
Kirigami.InlineMessage {
Layout.maximumWidth: Kirigami.Units.gridUnit * 26
+10 -2
View File
@@ -292,7 +292,7 @@ def similar_lighting(base_dir, lighting):
return None
DAYLIGHT_IR = 6.0 # ambient IR (the mono cameras' mean dark_mean) from which it's daylight
DAYLIGHT_IR = 6.0 # ambient IR (the mono cameras' mean dark_mean) from which it's daylight; see classify_lighting
def ambient_ir(ring):
@@ -306,7 +306,12 @@ def ambient_ir(ring):
def classify_lighting(ring):
""""daylight" or "indoor" from the room's infrared light, "" if it can't tell. Sunlight
carries a lot of infrared; lamps and LEDs hardly any, so a dim room and a bright one read
about the same (2026-10: 1.8 by one lamp, 2.2 in a lamp-lit room) and aren't told apart."""
about the same (2026-10: 1.8 by one lamp, 2.2 in a lamp-lit room) and aren't told apart.
Nor does a sunlit room reliably: the first daylight round (dataset PR #5, big sunlit windows)
read 2.38, since the windows are a small part of each picture and the mean barely moves. So
"indoor" means "no strong daylight on the cameras", and the window asks people to pick
daylight themselves. What did show it there: hands only ~1.15x as bright as their surroundings
(1.5-1.7x in lamp-lit rooms), which needs hands in view, so it's measured on the dataset side."""
ir = ambient_ir(ring)
if ir is None:
return ""
@@ -1575,6 +1580,9 @@ class Session:
self._save_session()
self._log("side cameras: %s (%s, %s)" % ("SWAPPED" if swapped else "as named", new["decided_by"],
json.dumps(new["evidence"])))
if new["state"] == "forced, disagrees":
self._log("side cameras: HANDS_SWAP_SIDES in ~/.config/frametop.conf forces names the hands say are "
"backwards; the recording goes by the hands. Set HANDS_SWAP_SIDES=auto.")
def _sides_swapped(self):
"""session.json's decision: True, False, or None (not known yet)."""
+5
View File
@@ -125,4 +125,9 @@ def read_live(path=None, ring_path=None, now_ns=None):
return None
except OSError:
return None
if (s.get("state") == "forced, disagrees" and s.get("swapped") is not None
and bool(s["swapped"]) == bool(s.get("names_swapped"))):
# An ft-hands built before 2026-10-06 kept a forced HANDS_SWAP_SIDES as the truth even
# when the hands disagreed. The hands are right: the truth is the other way round.
s = dict(s, swapped=not s["swapped"], decided_by="auto")
return s
+39
View File
@@ -91,6 +91,29 @@ class RulesTest(unittest.TestCase):
finally:
shutil.rmtree(d)
def test_read_live_forced(self):
"""HANDS_SWAP_SIDES=0 forced and the hands disagree: the hands are the truth, whether
ft-hands published them (built after 2026-10-06) or kept the forced value (before)."""
d = tempfile.mkdtemp()
try:
path = os.path.join(d, "sides.json")
def live(**kw):
s = dict(pid=1, ring_ino=0, mode="0", names_swapped=False,
updated_ns=time.clock_gettime_ns(time.CLOCK_MONOTONIC), **kw)
with open(path, "w") as f:
json.dump(s, f)
return sides.read_live(path)
self.assertEqual(live(state="forced", swapped=False, decided_by="config")["swapped"], False)
self.assertEqual(live(state="forced, agrees", swapped=False, decided_by="config")["swapped"], False)
old = live(state="forced, disagrees", swapped=False, decided_by="config")
self.assertEqual((old["swapped"], old["decided_by"]), (True, "auto"))
new = live(state="forced, disagrees", swapped=True, decided_by="auto")
self.assertEqual((new["swapped"], new["decided_by"]), (True, "auto"))
finally:
shutil.rmtree(d)
class TakesTest(unittest.TestCase):
"""A swapped session: one take's parts recorded before the decision (ft-camd's names) and
@@ -226,6 +249,22 @@ class SessionSidesTest(unittest.TestCase):
self.assertTrue(s._session_json["sides"]["reversed_from"]["swapped"])
self.assertFalse(takes.read_json(os.path.join(self.tmp, "session.json"))["sides"]["swapped"])
def test_read_sides_forced(self):
"""PR #4 on the dataset: HANDS_SWAP_SIDES=0 from the old example config, and the hands
disagree. The session takes the hands' answer, not the forced one."""
s = session.Session(os.path.join(self.tmp, "base"), {}, {}, "room", self.script, dry_run=True,
hands_dir=self.tmp)
s.session_dir = self.tmp
s._session_json = {"sides": {"swapped": None}}
self.live(mode="0", state="forced", swapped=False, decided_by="config", names_swapped=False, evidence=None)
s._read_sides(force=True)
self.assertIs(s._sides_swapped(), False)
self.live(mode="0", state="forced, disagrees", swapped=False, decided_by="config", names_swapped=False)
s._read_sides(force=True)
self.assertIs(s._sides_swapped(), True)
self.assertEqual(s._session_json["sides"]["decided_by"], "auto")
self.assertEqual(s._session_json["sides"]["reversed_from"]["decided_by"], "config")
def test_recorder_parts(self):
calls = []
+30 -6
View File
@@ -4,10 +4,11 @@
# ft-handsctl on|off (on the Frame) or hands/run.sh start|stop.
# Usage: hands/run.sh install|uninstall
# hands/run.sh caps # give ft-camd its capabilities again (a rebuild clears them)
# hands/run.sh uncaps # take them back, unless the Hand Recorder or the services use them
# hands/run.sh start|stop|restart|status|log [lines]
# install and caps need the password (sudo setcap, once per build of ft-camd): it's asked in
# the terminal, on the Frame or from a PC (frame_sudo in scripts/_env.sh, which also takes it
# from the repo's .env).
# install and caps need the password (sudo setcap, once per build of ft-camd), and so do
# uninstall and uncaps when they take the capabilities back. It's asked in the terminal, on the
# Frame or from a PC (frame_sudo in scripts/_env.sh, which also takes it from the repo's .env).
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
. "$root/scripts/_env.sh"
@@ -16,6 +17,10 @@ units="frametop-camd.service frametop-hands.service"
# pidfd_getfd on XRService (ptrace_scope=1), system-wide tracepoints, and their root-only
# format files. ft-camd drops them all once it has set up.
caps=cap_sys_ptrace,cap_perfmon,cap_dac_read_search+ep
# Installed, two things use ft-camd's capabilities: the Hand Recorder (hands/rec/install.sh: its
# menu entry) and the services. ft-cutouts uses them too, but installs nothing.
handrec_entry='~/.local/share/applications/frametop-handrec.desktop'
camd_unit='~/.config/systemd/user/frametop-camd.service'
sudo_run() { frame_sudo "$1"; }
@@ -29,6 +34,22 @@ set_caps() { # only when missing: a rebuild clears them, a reinstall doesn't
sudo_run "setcap $caps $bin && getcap $bin"
}
# A left-over binary shouldn't keep its read-any-file powers, but while the Hand Recorder or
# the services are installed, they still need them. Best effort: without the password it warns.
drop_caps() {
local bin who
bin=$(printf %q "$FRAME_REPO/hands/build/ft-camd")
who=$(on_frame "if ! { [ -x $bin ] && getcap $bin | grep -q cap_sys_ptrace; }; then echo none
elif [ -e $handrec_entry ]; then echo recorder
elif [ -e $camd_unit ]; then echo services
else echo nobody; fi") || who=none
case $who in
recorder) echo "kept ft-camd's capabilities: the Hand Recorder uses them" ;;
services) echo "kept ft-camd's capabilities: hand tracking's services use them (hands/run.sh uninstall)" ;;
nobody) sudo_run "setcap -r $bin" || echo "warning: couldn't drop ft-camd's capabilities (no password?)" >&2 ;;
esac
}
states="for u in $units; do echo \"\$u: \$(systemctl --user is-active \$u)\"; done"
case ${1:-status} in
@@ -43,11 +64,14 @@ case ${1:-status} in
mkdir -p ~/.local/bin && ln -sfn $(printf %q "$FRAME_REPO/hands/ft-handsctl") ~/.local/bin/ft-handsctl
$states; echo 'start it with: ft-handsctl on'" ;;
caps) set_caps ;;
uninstall) "$frame" --host "systemctl --user disable --now $units 2>/dev/null
uncaps) drop_caps ;;
uninstall)
"$frame" --host "systemctl --user disable --now $units 2>/dev/null
for u in $units; do rm -f ~/.config/systemd/user/\$u; done; systemctl --user daemon-reload
[ -L ~/.local/bin/ft-handsctl ] && rm -f ~/.local/bin/ft-handsctl; echo removed" ;;
[ -L ~/.local/bin/ft-handsctl ] && rm -f ~/.local/bin/ft-handsctl; echo removed"
drop_caps ;; # once the units are gone, so they don't count as a user
start|stop|restart) "$frame" --host "systemctl --user $1 $units; $states" ;;
status) "$frame" --host "$states; journalctl --user -u frametop-hands.service --no-pager -o cat -n 4" || true ;;
log) "$frame" --host "journalctl --user -u frametop-camd.service -u frametop-hands.service --no-pager -o short -n ${2:-30}" ;;
*) echo "usage: $0 install|uninstall|caps|start|stop|restart|status|log [lines]" >&2; exit 2 ;;
*) echo "usage: $0 install|uninstall|caps|uncaps|start|stop|restart|status|log [lines]" >&2; exit 2 ;;
esac
+6 -3
View File
@@ -27,7 +27,8 @@
// default) tells from the hands it tracks (track/sides.h): once it's sure, it exchanges the two
// cameras if they're backwards (the tracked views move with their images), and checks once
// more. 0 and 1 force the naming (1: exchanged; --swap-sides is --sides 1); it still checks,
// and warns if the hands disagree. The decision is published in /run/user/UID/frametop-hands/sides.json (see
// and if the hands disagree it warns and publishes what the hands say as the truth ("swapped"),
// so recordings are labelled right while tracking keeps the forced names. The decision is published in /run/user/UID/frametop-hands/sides.json (see
// write_sides below) and, for recordings, in DIR/sides.json. --record-only can't tell (it tracks
// nothing): under auto it records the ring's names as they are.
//
@@ -507,14 +508,16 @@ int main(int argc, char **argv) {
const bool backwards = v == SideCheck::Swapped; // relative to the names as they are now
const double after = (now - start) / 1e9;
const std::string ev = side_check.json(), text = side_check.summary();
if (sides_mode != "auto") { // forced: only say so
if (sides_mode != "auto") { // forced: the names stay; if the hands disagree, they're the truth
const std::string what = (sides_from == "option" ? "--sides " : "HANDS_SWAP_SIDES=") + sides_mode;
if (backwards)
std::printf("side cameras: %s looks WRONG: the hands say the side cameras are the other way round (%s). "
"Use auto.\n", what.c_str(), text.c_str());
"Tracking keeps the forced names; recordings are labelled by the hands. Use auto.\n",
what.c_str(), text.c_str());
else
std::printf("side cameras: %s agrees with the hands (%s)\n", what.c_str(), text.c_str());
sides_state = backwards ? "forced, disagrees" : "forced, agrees";
if (backwards) truth = !names_swapped, decided_by = "auto", decided_after_s = after;
decision_evidence = ev;
checking = false;
} else if (side_round == 0 || backwards) {
+70 -16
View File
@@ -66,12 +66,18 @@ Typing on a keyboard sends the helper "typing" (at most 4 times a second): it ta
pinches right after a key, since typing touches thumb to index like a pinch.
Keys also go to ft-screens (@ft_screens, the Frametop desktop's compositor), which
types them into the desktop screen that has focus: from pass-through keyboards, and
keys a pointer device passes through. 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, 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. With SHARE_KEYS=1 in ~/.config/frametop.conf,
types them into the desktop screen that has focus: from pass-through keyboards, a USB or
Bluetooth keyboard's media keys (its Consumer Control node, which has volume keys, so it's
never grabbed and gamescope has them too), and keys a pointer device passes through. In
pointer mode a mouse button passed through as a key (BTN_MOUSE..BTN_TASK, a side button for
Back) goes there too, and ft-screens gives it the screen the pointer is on, not the one
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,
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. With SHARE_KEYS=1 in ~/.config/frametop.conf,
a grabbed keyboard's keys also go out as "key <code> <value> <device name>" datagrams on
@frametop_keys, for programs that watch every keyboard for a hotkey and lose it to the grab.
It's off by default: any local process that binds that name first gets every key typed
@@ -153,6 +159,7 @@ KEY_A = 30
REL_X, REL_Y, REL_WHEEL, REL_MAX = 0x00, 0x01, 0x08, 0x0F
SCROLLS = {0x06, REL_WHEEL, 0x0B, 0x0C} # REL_HWHEEL, REL_WHEEL and their _HI_RES
BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA = 0x110, 0x111, 0x112, 0x113, 0x114
BTN_MOUSE, BTN_TASK = 0x110, 0x117 # mouse buttons, the first and the last
KEY_LEFTMETA, KEY_RIGHTMETA = 125, 126
KEY_MUTE, KEY_VOLUMEDOWN, KEY_VOLUMEUP = 113, 114, 115
# Volume keys are remapped to KEY_MACRO28, KEY_MACRO29 and KEY_MACRO30: above 255, so X11
@@ -498,6 +505,10 @@ class Pointer:
self.pending = 0
self.pending_since = 0.0
self.gaze_awake_until = 0.0 # the helper's gaze mode keeps the pointer until then
# Driver buttons this pointer pressed and hasn't released (trigger, b, x, joystick: the
# left, right, middle and back actions, from a mouse button, a mapped controller button
# or a key combination): pausing drops releases, so stand_down has to send them itself.
self.driver_down = set()
def send(self, command, droppable=False):
"""To the helper, in order, without blocking. While the helper doesn't keep up (place and
@@ -607,6 +618,10 @@ class Pointer:
self.wake(now)
self.flush()
self.send(f"btn {driver} {value}")
if value == 1:
self.driver_down.add(driver)
else:
self.driver_down.discard(driver)
elif value != 1:
return # the rest act on press
elif name in ("scroll_up", "scroll_down"):
@@ -706,18 +721,39 @@ class Pointer:
return wait
def stand_down(self):
"""Frametop is pausing: a pulse under way ends now, and the pointer lets go."""
"""Frametop is pausing: a pulse under way ends now, and the pointer lets go.
A click held into the pause (driver_down) comes up here, since pausing drops its
release; otherwise the driver keeps the button down and the virtual controller
reconnects with it pressed on resume. Gaze holds (gazekey, gazedrag, precision) aren't
tracked here: the helper ends them itself when the pointer hides.
The releases go before "hide", and "hide" follows them even when the pointer was off
already (the idle timeout or pointer_toggle with a button held): a helper built before
releases stopped waking it would wake on one and connect the virtual controller
during the game.
Not covered: gaze mode's held-back press (the helper's aim, before it becomes a real
press) turns into a click on its release. The helper drops it without a click when it
reads "hide" in the same loop as the release, as it normally does. If its socket was
full, the rest of these wait in the queue (send), "hide" can land a loop later, and
that press clicks once as the pause starts."""
releases = [f"btn {driver} 0" for driver in sorted(self.driver_down)]
self.driver_down.clear()
if self.system_release is not None:
self.send("btn system 0")
releases.append("btn system 0")
if self.claim_release is not None:
self.send("btn a 0")
releases.append("btn a 0")
if self.scroll_until is not None:
self.send("scroll 0 0")
releases.append("scroll 0 0")
for command in releases:
self.send(command)
self.system_at = self.system_release = self.claim_at = self.claim_release = self.scroll_until = None
self.dx = self.dy = self.pending = 0
self.gaze_awake_until = 0.0
if self.active:
if self.active or releases:
self.send("hide")
if self.active:
self.active = False
log("pointer off (paused)")
@@ -1019,9 +1055,14 @@ def main():
screens_down = set() # keys the desktop was told went down and not yet up (see reconcile_desktop_keys)
def to_screens(code, value):
"""A key for the desktop screens (ft-screens decides whether it types)."""
if value in (0, 1) and code < BTN_MISC:
def to_screens(code, value, button=False):
"""A key for the desktop screens (ft-screens decides whether it types): one below
BTN_MISC, or with button, a mouse button (BTN_MOUSE..BTN_TASK), which ft-screens gives
the screen the pointer is on. Nothing else (gamepad, joystick, digitizer buttons, keys
from KEY_OK up), but the release of anything the desktop has down."""
if value not in (0, 1):
return
if code < BTN_MISC or (button and BTN_MOUSE <= code <= BTN_TASK) or (not value and code in screens_down):
try:
screens_sock.sendto(f"key {code} {value}".encode(), SCREENS)
except OSError:
@@ -1281,9 +1322,12 @@ def main():
vr_bind(time.monotonic()) # a helper that's already running keeps its buttons in step
waiting = False # a keyboard's grab waits for its keys to come up
# A relay that went away with a key down left it down on the desktop, where this one
# never sent it: modifiers come up there now (a release of a key that isn't down is nothing).
# never sent it: modifiers and mouse buttons come up there now (a release of a key that
# isn't down is nothing).
for code in sorted(MODIFIERS):
to_screens(code, 0)
for code in range(BTN_MOUSE, BTN_TASK + 1):
to_screens(code, 0, button=True)
while True:
now = time.monotonic()
pointer = state["pointer"]
@@ -1372,6 +1416,12 @@ def main():
volume.key(fd, code, value, now)
continue
if node.role == "volume":
# A keyboard's media keys (its Consumer Control node) go to the desktop like a
# pass-through keyboard's, and nowhere else: the node isn't grabbed, so gamescope
# and SteamVR have them already. Not platform buttons: the headset's click button
# is KEY_SELECT on gpio-keys (BUS_HOST). Nor a volume key a remap missed.
if etype == EV_KEY and node.bus in (BUS_USB, BUS_BLUETOOTH) and code not in VOLUME_CODES:
to_screens(code, value)
continue
if node.role != "pointer":
# Observed only, unless typing goes to the desktop. Key combinations work on
@@ -1406,7 +1456,11 @@ def main():
continue
target = mouse if code >= BTN_MISC else keyboard
target.emit(etype, code, value)
to_screens(code, value)
# A mouse button goes to the desktop only when pointer mode passes it through
# as a key (a side button for Back there). The rest, like every click with
# POINTER=0 or paused, reach the desktop through a laser, if at all. (A
# key-mapped button still goes to the virtual mouse too.)
to_screens(code, value, button=bool(pointer))
if value:
node.held.add(code)
else:
+151 -21
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Offline test of the input relay's key combinations and modifier taps.
"""Offline test of the input relay's key combinations and modifier taps, and of which keys and
mouse buttons reach the desktop (ft-screens).
Runs the relay's main() against a fake pass-through keyboard and a fake mouse (pipes), with
every socket it sends to renamed, no uinput devices, no grabs, and ft-steam swapped for a
logger. Pausing (game_pause.py) is a stub: no threads, state file, or services. Nothing reaches
the live desktop, SteamVR, or the running relay, so it's safe next to them. Rules come from the
test, not ~/.config/frametop-input.json.
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
every socket it sends to renamed, no uinput devices (they record what they're sent), no grabs,
no volume changes, and ft-steam swapped for a logger. Pausing (game_pause.py) is a stub: no
threads, state file, or services. Nothing reaches the live desktop, SteamVR, or the running
relay, so it's safe next to them. Rules come from the test, not ~/.config/frametop-input.json.
input/test/keys-test.py [RELAY] (default: input/input-relay.py next to this folder)
"""
@@ -72,38 +74,55 @@ with open(relay.FT_STEAM, "w") as f:
os.chmod(relay.FT_STEAM, 0o755)
class NoDevice:
def __init__(self, *args, **kwargs):
pass
EMITTED = [] # (virtual device, code, value): what the virtual mouse and keyboard were sent
VOLUME = [] # (code, value): volume keys the relay handled itself (no wpctl here)
def emit(self, *args):
pass
class NoDevice:
"""A virtual device that only records what it's sent."""
def __init__(self, name, *args, **kwargs):
self.name = name.split()[-1] # mouse or keyboard
def emit(self, etype, code, value):
EMITTED.append((self.name, code, value))
def sync(self):
pass
relay.Virtual = NoDevice
relay.Volume.key = lambda self, fd, code, value, now: VOLUME.append((code, value))
relay.log = lambda *args: None # the relay's own log
bindings = {"now": None} # the rules' key_bindings; None: the relay's defaults
devices = {"roles": {}, "buttons": {}} # the rules' "devices" roles and per-device "buttons"
MOUSE_ID = "usb:0003:0004:test mouse" # the fake mouse's id (Node.id)
def read_rules(path=None):
rules = {"devices": {}, "buttons": {}, "controller_buttons": {}}
rules = {"devices": {i: {"role": r} for i, r in devices["roles"].items()},
"buttons": {i: dict(b) for i, b in devices["buttons"].items()}, "controller_buttons": {}}
rules["key_bindings"] = dict(relay.DEFAULT_KEY_BINDINGS if bindings["now"] is None else bindings["now"])
return rules
relay.read_rules = read_rules
relay.read_config = lambda path=None: {"POINTER": "0"}
conf = {"POINTER": "0"} # ~/.config/frametop.conf, as the relay reads it
relay.read_config = lambda path=None: dict(conf)
# The fake devices: /dev/input/event900 (keyboard) and event901 (mouse), each a pipe.
# The fake devices, each a pipe: /dev/input/event900 (keyboard), event901 (mouse), event902 (the
# keyboard's Consumer Control node: media and volume keys), event903 (the headset's gpio-keys).
kb_r, kb_w = os.pipe()
ms_r, ms_w = os.pipe()
for fd in (kb_r, ms_r):
cc_r, cc_w = os.pipe()
gp_r, gp_w = os.pipe()
for fd in (kb_r, ms_r, cc_r, gp_r):
os.set_blocking(fd, False)
FAKE = {"/dev/input/event900": kb_r, "/dev/input/event901": ms_r}
HELD = {kb_r: set(), ms_r: set()} # what EVIOCGKEY says each holds
FAKE = {"/dev/input/event900": kb_r, "/dev/input/event901": ms_r, "/dev/input/event902": cc_r,
"/dev/input/event903": gp_r}
READ_END = {kb_w: kb_r, ms_w: ms_r, cc_w: cc_r, gp_w: gp_r}
HELD = {kb_r: set(), ms_r: set(), cc_r: set(), gp_r: set()} # what EVIOCGKEY says each holds
BUS_HOST = 0x19
class Inode:
@@ -112,7 +131,7 @@ class Inode:
fake_os = type(os)("os")
fake_os.__dict__.update(os.__dict__)
fake_os.listdir = lambda path: ["event900", "event901"] if path == "/dev/input" else os.listdir(path)
fake_os.listdir = lambda path: [p.split("/")[-1] for p in FAKE] if path == "/dev/input" else os.listdir(path)
fake_os.stat = lambda path, *a, **k: Inode() if path in FAKE else os.stat(path, *a, **k)
fake_os.access = lambda path, mode, *a, **k: path in FAKE or os.access(path, mode, *a, **k)
relay.os = fake_os
@@ -135,7 +154,17 @@ relay.fcntl = fake_fcntl
def probe(path):
if FAKE[path] == kb_r:
return relay.Node(path, kb_r, "test keyboard", relay.BUS_USB, 1, 2, "", False, True)
return relay.Node(path, ms_r, "test mouse", relay.BUS_USB, 3, 4, "", True, False)
if FAKE[path] == ms_r:
return relay.Node(path, ms_r, "test mouse", relay.BUS_USB, 3, 4, "", True, False)
# Nodes with volume keys (role "volume"). take_volume does nothing with --no-grab, so they
# come as if it had remapped their volume keys to the stand-ins.
if FAKE[path] == cc_r:
node = relay.Node(path, cc_r, "test keyboard Consumer Control", relay.BUS_USB, 1, 2, "", False, False,
candidate=False)
else:
node = relay.Node(path, gp_r, "gpio-keys", BUS_HOST, 0, 0, "", False, False, candidate=False)
node.remapped = True
return node
relay.probe = probe
@@ -152,7 +181,7 @@ def send(fd, etype, code, value):
def key(code, value, fd=kb_w):
held = HELD[ms_r if fd == ms_w else kb_r]
held = HELD[READ_END[fd]]
(held.add if value else held.discard)(code)
send(fd, relay.EV_KEY, code, value)
@@ -167,6 +196,14 @@ def typed():
return got
def emitted():
"""What the virtual mouse and keyboard were sent since the last call."""
got = []
while EMITTED:
got.append(EMITTED.pop(0))
return got
def use(key_bindings):
bindings["now"] = key_bindings
c = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
@@ -212,7 +249,8 @@ F24 = ["key 194 1", "key 194 0"]
def tests():
time.sleep(1.5) # the relay's first device scan
typed()
check("a relay that starts releases the modifiers and mouse buttons on the desktop", typed(),
[f"key {c} 0" for c in sorted(relay.MODIFIERS)] + [f"key {c} 0" for c in range(0x110, 0x118)])
key(META, 1); key(META, 0)
check("Meta tap (default): Steam menu", lines(steam_log), ["menu"])
check("Meta tap: the desktop gets F24 before Meta's release", typed(), ["key 125 1"] + F24 + ["key 125 0"])
@@ -237,6 +275,37 @@ def tests():
check("Meta+J (default gaze click): no tap", lines(steam_log), ["menu", "menu"])
check("Meta+J: F24, Meta up early, its real release dropped", typed(), ["key 125 1"] + F24 + ["key 125 0"])
# Mouse buttons reach the desktop only when pointer mode passes them through as keys.
emitted()
key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w); key(relay.BTN_SIDE, 1, ms_w); key(relay.BTN_SIDE, 0, ms_w)
check("POINTER=0: clicks don't reach the desktop", typed(), [])
check("POINTER=0: clicks go to the virtual mouse", emitted(),
[("mouse", relay.BTN_LEFT, 1), ("mouse", relay.BTN_LEFT, 0), ("mouse", relay.BTN_SIDE, 1),
("mouse", relay.BTN_SIDE, 0)])
# A keyboard's Consumer Control node isn't grabbed, so gamescope has its keys already: its
# media keys go to the desktop and nowhere else, its volume keys to the relay's volume
# handling only. The headset's gpio-keys (BUS_HOST) send nothing on.
PLAYPAUSE, SELECT, VOLUP = 164, 353, relay.KEY_VOLUMEUP
key(PLAYPAUSE, 1, cc_w); key(PLAYPAUSE, 0, cc_w)
check("media key: reaches the desktop", typed(), ["key 164 1", "key 164 0"])
check("media key: no virtual device", emitted(), [])
standin = relay.VOLUME_STANDIN[VOLUP]
key(standin, 1, cc_w); key(standin, 0, cc_w)
check("volume key (its stand-in): the relay's volume handling only", (typed(), emitted(), VOLUME[:]),
([], [], [(standin, 1), (standin, 0)]))
VOLUME.clear()
key(VOLUP, 1, cc_w); key(VOLUP, 0, cc_w)
check("a volume key a remap missed reaches nothing", (typed(), emitted(), VOLUME[:]), ([], [], []))
key(SELECT, 1, gp_w); key(SELECT, 0, gp_w)
check("the headset's click button (gpio-keys, KEY_SELECT) reaches nothing", (typed(), emitted()), ([], []))
key(PLAYPAUSE, 1, cc_w)
time.sleep(1.2) # the relay's once-a-second check (reconcile_desktop_keys)
check("a media key held for over a second stays down on the desktop", typed(), ["key 164 1"])
HELD[cc_r].discard(PLAYPAUSE) # its node lets go, and the release never comes
time.sleep(1.2)
check("...and comes up there once its node doesn't hold it", typed(), ["key 164 0"])
use({"29+42+33": f"command:echo combo >> {cmd_log}", "125": "none"})
key(CTRL, 1); key(SHIFT, 1); key(F, 1); key(F, 0); key(SHIFT, 0); key(CTRL, 0)
check("Ctrl+Shift+F runs its command", lines(cmd_log), ["combo"])
@@ -265,6 +334,67 @@ def tests():
key(META, 1); key(J, 1); key(J, 0); key(META, 0)
check("resumed: Meta+J is a combination again", typed(), ["key 125 1"] + F24 + ["key 125 0"])
# Pointer mode: a click held into a pause comes up as it starts, then the pointer hides
# (stand_down); the release that comes during the pause reaches no one.
helper = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
helper.bind(relay.HELPER)
helper.settimeout(0.05)
def clicks():
"""The helper's button commands and hides since the last call (not the claim pulse)."""
got = []
while True:
try:
m = helper.recv(256).decode()
except socket.timeout:
return got
if m == "hide" or (m.startswith("btn ") and not m.startswith("btn a ")):
got.append(m)
conf["POINTER"] = "1"
use({})
key(relay.BTN_LEFT, 1, ms_w)
time.sleep(0.5) # the claim pulse, 0.3 s after the pointer wakes
check("pointer mode: a held left button reaches the helper", clicks(), ["btn trigger 1"])
pause("on")
check("pause with the left button held: it comes up, then the pointer hides", clicks(),
["btn trigger 0", "hide"])
key(relay.BTN_LEFT, 0, ms_w)
check("paused: its release reaches no one", clicks(), [])
pause("off")
key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w)
check("resumed: a click goes to the helper again", clicks(), ["btn trigger 1", "btn trigger 0"])
# Pointer mode: a click is the helper's alone. A side button mapped to "key" (Back on the
# desktop) goes to the desktop too, and its release still does after a pause starts; paused,
# clicks go to the virtual mouse only.
check("pointer mode: a click doesn't reach the desktop", typed(), [])
emitted()
devices["buttons"] = {MOUSE_ID: {str(relay.BTN_SIDE): "key"}}
use({})
key(relay.BTN_SIDE, 1, ms_w)
check("pointer mode: a side button mapped to key reaches the desktop", typed(), [f"key {relay.BTN_SIDE} 1"])
check("...and the virtual mouse, not the helper", (emitted(), clicks()), ([("mouse", relay.BTN_SIDE, 1)], []))
pause("on")
clicks()
key(relay.BTN_SIDE, 0, ms_w)
check("paused: its release still reaches the desktop", typed(), [f"key {relay.BTN_SIDE} 0"])
emitted()
key(relay.BTN_SIDE, 1, ms_w); key(relay.BTN_SIDE, 0, ms_w); key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w)
check("paused: clicks don't reach the desktop", typed(), [])
check("paused: clicks go to the virtual mouse", emitted(),
[("mouse", relay.BTN_SIDE, 1), ("mouse", relay.BTN_SIDE, 0), ("mouse", relay.BTN_LEFT, 1),
("mouse", relay.BTN_LEFT, 0)])
pause("off")
clicks()
devices["roles"] = {MOUSE_ID: "passthrough"}
use({})
key(relay.BTN_LEFT, 1, ms_w); key(relay.BTN_LEFT, 0, ms_w); key(relay.BTN_SIDE, 1, ms_w); key(relay.BTN_SIDE, 0, ms_w)
check("a pass-through mouse's clicks don't reach the desktop", typed(), [])
check("...nor a virtual device or the helper", (emitted(), clicks()), ([], []))
devices["roles"], devices["buttons"] = {}, {}
conf["POINTER"] = "0"
use(None) # the defaults: Meta+Alt+Tab and Meta+Alt+Shift+Tab spin the panels (ft-screens)
key(META, 1); key(ALT, 1); key(TAB, 1); key(TAB, 0); key(ALT, 0); key(META, 0)
got = typed()
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Offline test of the relay's pointer standing down when Frametop pauses: a click still held
when the pause starts (a mouse button, a mapped controller button, or a key combination mapped
to left, right, middle or back) is released by stand_down, and "hide" follows the release, even
when the pointer was off already. Gaze holds (gazekey, gazedrag, precision) are the helper's
to end, so stand_down sends nothing for them. The pointer's socket is a recorder, so nothing
reaches the helper, SteamVR, or the running relay.
input/test/pause-buttons-test.py [RELAY]
"""
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")
tag = f"ft_pause_buttons_test_{os.getpid()}"
src = open(relay_path).read().replace('"\\0frametop_relay"', f'"\\0{tag}_relay"')
# macOS has no SOCK_NONBLOCK; the pointer's socket is replaced below anyway.
src = src.replace("socket.SOCK_DGRAM | socket.SOCK_NONBLOCK", "socket.SOCK_DGRAM")
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)))
exec(compile(src, 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)
class Recorder:
"""Stands in for the helper's socket: remembers every command, in order."""
def __init__(self):
self.sent = []
def sendto(self, data, addr):
self.sent.append(data.decode())
def pointer():
p = relay.Pointer(0.02, 3600.0)
p.sock = Recorder()
return p
def since(p, mark):
"""What the pointer sent the helper after mark (a length of p.sock.sent)."""
return p.sock.sent[mark:]
# ---------------------------------------------------------------- the fix
p = pointer()
p.action("left", 1, 10.0) # a mouse click (or drag) held: btn trigger 1
check("press reaches the helper", p.sock.sent, ["show", "recenter", "btn trigger 1"])
mark = len(p.sock.sent)
p.stand_down() # Frametop pauses while it's held
check("stand_down releases the held button, then hides", since(p, mark), ["btn trigger 0", "hide"])
# The release that pausing drops later is already covered: the button is no longer down,
# so a stray second stand_down sends nothing.
mark = len(p.sock.sent)
p.stand_down()
check("a second stand_down sends nothing more", since(p, mark), [])
# Two buttons down at once: a left drag tilted with the right button
p = pointer()
p.action("left", 1, 10.0)
p.action("right", 1, 10.5)
mark = len(p.sock.sent)
p.stand_down()
check("both held buttons are released, then it hides", since(p, mark), ["btn b 0", "btn trigger 0", "hide"])
# A mapped controller button and a key combination take the same path as the mouse.
p = pointer()
p.action("middle", 1, 10.0, "right") # a controller button mapped to middle
p.action("back", 1, 10.1, "keyboard") # a key combination mapped to back
mark = len(p.sock.sent)
p.stand_down()
check("controller and key combination clicks are released too", since(p, mark),
["btn joystick 0", "btn x 0", "hide"])
# ---------------------------------------------------------------- the pointer already off
# A release with no "hide" after it would wake a helper that wakes on any btn, connecting the
# virtual controller during the game.
p = pointer()
p.idle = 30.0
p.action("left", 1, 10.0)
for t in (10.3, 10.4, 41.0): # the claim pulse, then 30 s with no mouse input
p.tick(t)
check("held 30 s with no mouse input: the pointer goes off", (p.active, p.sock.sent[-1]), (False, "hide"))
mark = len(p.sock.sent)
p.stand_down()
check("idle with a button held: the release, then hide", since(p, mark), ["btn trigger 0", "hide"])
p = pointer()
p.action("left", 1, 10.0)
p.action("pointer_toggle", 1, 10.5)
mark = len(p.sock.sent)
p.stand_down()
check("pointer toggled off with a button held: the release, then hide", since(p, mark),
["btn trigger 0", "hide"])
# ---------------------------------------------------------------- the ordinary path
p = pointer()
p.action("left", 1, 10.0)
p.action("left", 0, 11.0) # released before the pause
mark = len(p.sock.sent)
p.stand_down()
check("a button released before the pause: stand_down only hides", since(p, mark), ["hide"])
p = pointer()
p.idle = 30.0
p.action("left", 1, 10.0)
p.action("left", 0, 11.0)
for t in (10.3, 10.4, 42.0): # off by itself
p.tick(t)
mark = len(p.sock.sent)
p.stand_down()
check("nothing held and the pointer off: stand_down sends nothing", since(p, mark), [])
# Gaze holds go to the helper as their own commands, and it ends them when the pointer hides.
p = pointer()
p.action("gaze_left", 1, 10.0, "keyboard") # Meta+J held
p.action("gaze_drag", 1, 10.1, "mouse")
mark = len(p.sock.sent)
p.stand_down()
check("gaze holds: stand_down sends only hide (the helper ends them)", since(p, mark), ["hide"])
print()
if failures:
print(f"{len(failures)} failed: {', '.join(failures)}")
sys.exit(1)
print("all ok")
+1
View File
@@ -93,6 +93,7 @@ step "7/10 multi-screen desktop (ft-screens), Frametop Input Settings, and Frame
"$root/remote/install.sh"
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"
step "8/10 gaze mode (optional, experimental: the pointer goes where you look)"
gaze=0
+18 -3
View File
@@ -1563,8 +1563,19 @@ int main() {
// (Moves were taken first, above.)
const bool mouseInput = std::strncmp(buf, "btn", 3) == 0 || std::strncmp(buf, "scroll", 6) == 0;
if (mouseInput) lastMouse = Clock::now();
// Any mouse input wakes the pointer (after a controller took over, or a helper restart).
if (!active && mouseInput) wake(Clock::now());
// Mouse input wakes the pointer (after a controller took over, or a helper restart),
// but a release doesn't (a button up, a scroll back to 0 0): the relay sends those with
// the pointer off when Frametop pauses for a game (input-relay.py stand_down), and
// waking would connect the virtual controller during the game. The release is still
// handled below (it ends its press, or goes to the driver), so no button stays down.
{
char name[16];
int v = 1;
double sx = 1, sy = 1;
const bool release = (std::sscanf(buf, "btn %15s %d", name, &v) == 2 && v == 0) ||
(std::sscanf(buf, "scroll %lf %lf", &sx, &sy) == 2 && sx == 0 && sy == 0);
if (!active && mouseInput && !release) wake(Clock::now());
}
char key[128];
double px, py, pz, pyaw, ppitch, proll = 0, pgrab = -1;
if (std::sscanf(buf, "grabprobe %127s", key) == 1) {
@@ -2394,7 +2405,11 @@ int main() {
// A held-back press (see the top): held still long enough, it's a real press (a drag);
// released, it's a click where the pointer is now (this frame's pose has gone out).
if (!active) aimHeld = aimRight = clickPress = aimHand = confirmLesson = false; // released meanwhile: nothing to click
// Released meanwhile: nothing to click, and the click it was due (pressRight: a right one) is
// forgotten too, or the next press after the pointer wakes would go out as a right click.
// A "hide" read a loop after the release is too late: the click has gone out by then
// (input-relay.py stand_down).
if (!active) aimHeld = aimRight = clickPress = pressRight = aimHand = confirmLesson = false;
if (aimHeld && !aimHand && nudgeMoved < 0.2 && tnow - aimSince >= std::chrono::duration<double>(gazeHold)) {
aimHeld = false;
gazeBack = true;
+49 -2
View File
@@ -63,6 +63,7 @@
#include "vr.h"
#include "controller-click.h"
#include "relay-buttons.h"
#define MAX_SCREENS 24 // screens and spare outputs
// A screen counts as playing a video while its last VIDEO_COMMITS commits each redrew at
@@ -130,6 +131,7 @@ struct server {
uint32_t watch_until;
uint32_t typed_ms; // the last key sent to the desktop: its screen counts as focused
struct screen *pointer_focus;
struct ft_relay_buttons relay_buttons; // the input relay's mouse buttons held on the seat
struct ft_controller_click controller_click;
pid_t child;
// Where typing goes: the screens after a click on one, Steam after a click on another
@@ -174,6 +176,9 @@ static void track_buffer(struct server *s, struct wlr_buffer *buffer) {
// ---------------------------------------------------------------- screens
static bool relay_can_press(struct server *s);
static void release_relay_buttons(struct server *s, const char *why);
// A video (or anything moving over a large area) on a screen you don't look at keeps the
// full frame rate (see frame_interval): its commits keep redrawing much of it, and keep
// coming as fast as its rate lets them. A cursor blinking or a spinner turning redraws a
@@ -247,7 +252,10 @@ static void screen_destroy(struct wl_listener *l, void *data) {
wlr_log(WLR_INFO, "screen %d closed", sc->index + 1);
if (sc->held) wlr_buffer_unlock(sc->held);
ft_vr_screen_destroy(sc->index);
if (sc->server->pointer_focus == sc) sc->server->pointer_focus = NULL;
if (sc->server->pointer_focus == sc) {
release_relay_buttons(sc->server, "its screen closed");
sc->server->pointer_focus = NULL;
}
if (sc->decoration) wl_list_remove(&sc->decoration_destroy.link);
sc->server->screens[sc->index] = NULL;
wl_list_remove(&sc->commit.link);
@@ -410,6 +418,7 @@ static void handle_vr_event(const struct ft_event *e, void *data) {
break;
case FT_LEAVE:
if (s->pointer_focus == sc) {
release_relay_buttons(s, "the pointer left the screens");
wlr_seat_pointer_notify_clear_focus(s->seat);
s->pointer_focus = NULL;
}
@@ -499,6 +508,8 @@ static int tick(int fd, uint32_t mask, void *data) {
const ssize_t got = read(fd, &expirations, sizeof expirations); // clears it; how many doesn't matter
(void)got;
ft_vr_poll(handle_vr_event, s);
if (s->relay_buttons.held && !relay_can_press(s))
release_relay_buttons(s, ft_vr_paused() ? "paused" : "its screen hid");
if (++s->ticks % 9 == 0) keys_update(s);
if (s->kb_close_at && s->ticks >= s->kb_close_at) {
s->kb_close_at = 0;
@@ -553,13 +564,49 @@ static void send_key(struct server *s, uint32_t code, int pressed) {
wlr_seat_keyboard_notify_key(s->seat, ev.time_msec, code, ev.state);
}
// The input relay's mouse buttons (relay-buttons.h) can be pressed: the pointer is on a
// screen that shows, and nothing's paused.
static bool relay_can_press(struct server *s) {
return s->pointer_focus && !ft_vr_paused() && ft_vr_screen_visible(s->pointer_focus->index);
}
// A mouse button from the input relay (pointer mode passes a mouse's side button through as
// a key, for Back): to the screen the pointer is on, like a laser's click, wherever typing goes.
static void relay_button(struct server *s, uint32_t code, bool pressed, char *reply, int size) {
if (!ft_relay_button(&s->relay_buttons, code, pressed, relay_can_press(s)))
return (void)snprintf(reply, size, pressed ? "ok no pointer, or held" : "ok not held");
wlr_seat_pointer_notify_button(s->seat, now_ms(), code,
pressed ? WL_POINTER_BUTTON_STATE_PRESSED : WL_POINTER_BUTTON_STATE_RELEASED);
wlr_seat_pointer_notify_frame(s->seat);
snprintf(reply, size, "ok");
}
// ...and released by us when the pointer leaves the screens, its screen hides or closes, or
// everything pauses: before the pointer leaves that screen, so KWin gets the releases.
static void release_relay_buttons(struct server *s, const char *why) {
if (!s->relay_buttons.held) return;
uint32_t code;
while ((code = ft_relay_buttons_take(&s->relay_buttons))) {
wlr_seat_pointer_notify_button(s->seat, now_ms(), code, WL_POINTER_BUTTON_STATE_RELEASED);
wlr_log(WLR_INFO, "relay button %u released (%s)", code, why);
}
wlr_seat_pointer_notify_frame(s->seat);
}
// Keys from the input relay (physical keyboards): "key <evdev code> <1 press|0 release>".
// They go to the screen KWin has keyboard focus on (the last one clicked), while typing
// goes to the desktop (keys_update). The release of a key the desktop got the press for
// always goes through, or the key stays held there (a modifier held as typing moves to
// Steam would otherwise modify every key typed after it).
// Steam would otherwise modify every key typed after it). Mouse buttons go where the
// pointer is instead (relay_button), and codes that are neither go nowhere.
static void handle_key(struct server *s, uint32_t code, int value, char *reply, int size) {
if (value == 2) return (void)snprintf(reply, size, "ok repeat ignored"); // KWin repeats itself
const enum ft_relay_key kind = ft_relay_key_kind(code);
if (kind == FT_RELAY_BUTTON) {
relay_button(s, code, value != 0, reply, size);
return;
}
if (kind == FT_RELAY_DROP) return (void)snprintf(reply, size, "ok not a key or mouse button");
if (value || !key_held(&s->keyboard, code)) {
if (!s->seat->keyboard_state.focused_surface) return (void)snprintf(reply, size, "ok no focus");
if (!s->keys_desktop) return (void)snprintf(reply, size, "ok typing goes to Steam");
+55
View File
@@ -0,0 +1,55 @@
#ifndef FT_RELAY_BUTTONS_H
#define FT_RELAY_BUTTONS_H
#include <stdbool.h>
#include <stdint.h>
#include <linux/input-event-codes.h>
// Keys from the input relay ("key <code> <value>" on the control socket). Keys go where
// typing goes (compositor.c, handle_key). Mouse buttons, a mouse's side buttons that pointer
// mode passes through as keys, go where the pointer is, like a laser's click: a press needs
// the pointer on a visible screen, nothing paused, and that button not held already, and a
// release goes whenever its press went, whatever changed since. When the pointer leaves the
// screens, or its screen hides or closes, or everything pauses, ft-screens releases the held
// ones itself (ft_relay_buttons_take), and the relay's releases later find nothing held.
// wlroots counts presses per button, so one release lost would swallow that button for good
// (a laser's left click too), and KWin would keep it held.
enum ft_relay_key { FT_RELAY_DROP, FT_RELAY_KEY, FT_RELAY_BUTTON };
// What a code from the relay is: a key for the keyboard (below BTN_MISC, and KEY_OK up to
// BTN_TRIGGER_HAPPY: xkeyboard-config names keycodes above 255 too), a mouse button for the
// pointer (BTN_MOUSE..BTN_TASK), or nothing to send (joystick, gamepad and digitizer
// buttons, BTN_TRIGGER_HAPPY and up).
static inline enum ft_relay_key ft_relay_key_kind(uint32_t code) {
if (code < BTN_MISC || (code >= KEY_OK && code < BTN_TRIGGER_HAPPY)) return FT_RELAY_KEY;
if (code >= BTN_MOUSE && code <= BTN_TASK) return FT_RELAY_BUTTON;
return FT_RELAY_DROP;
}
struct ft_relay_buttons {
uint8_t held; // pressed on the seat and not released, a bit each (1 << (code - BTN_MOUSE))
};
// A relay button pressed or released: true if it goes to the seat. can_press: the pointer
// is on a visible screen, and nothing's paused.
static inline bool ft_relay_button(struct ft_relay_buttons *b, uint32_t code, bool pressed, bool can_press) {
if (ft_relay_key_kind(code) != FT_RELAY_BUTTON) return false;
const uint8_t bit = (uint8_t)(1u << (code - BTN_MOUSE));
if (pressed) {
if (!can_press || (b->held & bit)) return false;
b->held |= bit;
return true;
}
if (!(b->held & bit)) return false;
b->held &= (uint8_t)~bit;
return true;
}
// The next held button, no longer held here (0: none), for ft-screens to release itself.
static inline uint32_t ft_relay_buttons_take(struct ft_relay_buttons *b) {
for (uint32_t i = 0; i <= BTN_TASK - BTN_MOUSE; ++i)
if (b->held & (1u << i)) {
b->held &= (uint8_t)~(1u << i);
return BTN_MOUSE + i;
}
return 0;
}
#endif
+37
View File
@@ -0,0 +1,37 @@
#include <assert.h>
#include "../relay-buttons.h"
int main(void) {
// What goes where: keys to the keyboard, mouse buttons to the pointer, the rest nowhere.
assert(ft_relay_key_kind(KEY_A) == FT_RELAY_KEY && ft_relay_key_kind(KEY_PLAYPAUSE) == FT_RELAY_KEY);
assert(ft_relay_key_kind(BTN_LEFT) == FT_RELAY_BUTTON && ft_relay_key_kind(BTN_SIDE) == FT_RELAY_BUTTON);
assert(ft_relay_key_kind(BTN_EXTRA) == FT_RELAY_BUTTON && ft_relay_key_kind(BTN_TASK) == FT_RELAY_BUTTON);
for (uint32_t c = BTN_MISC; c < BTN_MOUSE; ++c) assert(ft_relay_key_kind(c) == FT_RELAY_DROP);
for (uint32_t c = BTN_TASK + 1; c < KEY_OK; ++c) assert(ft_relay_key_kind(c) == FT_RELAY_DROP); // joystick, gamepad, digitizer
assert(ft_relay_key_kind(KEY_OK) == FT_RELAY_KEY && ft_relay_key_kind(BTN_TRIGGER_HAPPY - 1) == FT_RELAY_KEY);
assert(ft_relay_key_kind(BTN_TRIGGER_HAPPY) == FT_RELAY_DROP && ft_relay_key_kind(KEY_MAX) == FT_RELAY_DROP);
assert(ft_relay_key_kind(KEY_MAX + 1) == FT_RELAY_DROP);
struct ft_relay_buttons b = {0};
// No pointer on a visible screen (or paused): the press is dropped, and so is its release.
assert(!ft_relay_button(&b, BTN_SIDE, true, false) && !b.held);
assert(!ft_relay_button(&b, BTN_SIDE, false, true));
// A release without a press is nothing, whatever the pointer does.
assert(!ft_relay_button(&b, BTN_EXTRA, false, false) && !ft_relay_button(&b, BTN_EXTRA, false, true));
// A press on a screen, then typing moves to Steam or the pointer leaves: the release still goes.
assert(ft_relay_button(&b, BTN_SIDE, true, true) && b.held == 1u << (BTN_SIDE - BTN_MOUSE));
assert(!ft_relay_button(&b, BTN_SIDE, true, true)); // a second press of a held button
assert(ft_relay_button(&b, BTN_SIDE, false, false) && !b.held);
assert(!ft_relay_button(&b, BTN_SIDE, false, false)); // once
// Not a mouse button: never the pointer's.
assert(!ft_relay_button(&b, KEY_A, true, true) && !ft_relay_button(&b, BTN_JOYSTICK, true, true));
assert(!ft_relay_button(&b, BTN_TRIGGER_HAPPY, true, true) && !b.held);
// ft-screens lets go itself (the pointer left, its screen hid, a pause): each held button
// once, then the relay's own releases find nothing held.
assert(ft_relay_button(&b, BTN_LEFT, true, true) && ft_relay_button(&b, BTN_TASK, true, true));
assert(ft_relay_buttons_take(&b) == BTN_LEFT && ft_relay_buttons_take(&b) == BTN_TASK);
assert(ft_relay_buttons_take(&b) == 0 && !b.held);
assert(!ft_relay_button(&b, BTN_LEFT, false, true) && !ft_relay_button(&b, BTN_TASK, false, true));
// ...and the next press goes through again.
assert(ft_relay_button(&b, BTN_LEFT, true, true) && ft_relay_button(&b, BTN_LEFT, false, true));
}
+15 -4
View File
@@ -1146,23 +1146,29 @@ void EndDrag(Screen &s) {
ApplyAlpha(s);
}
// Run `ft-layout <cmd>` in the background, logging to /tmp/frametop-layout.log.
// Run `ft-layout <cmd>` in the background, logging to the host's runtime directory
// (XDG_RUNTIME_DIR, not the nested desktop's; else /tmp with O_NOFOLLOW: a symlink there
// must not be followed). The desktop start truncates the same log.
void RunLayout(const char *cmd) {
char exe[PATH_MAX];
if (!realpath("/proc/self/exe", exe)) return;
const char *runtime = std::getenv("XDG_RUNTIME_DIR");
std::string logname = std::string(runtime && *runtime ? runtime : "/tmp") + "/frametop-layout.log";
std::string layout(exe); // <repo>/screens/build/ft-screens -> <repo>/layout/ft-layout
for (int up = 0; up < 3 && layout.rfind('/') != std::string::npos; ++up) layout.resize(layout.rfind('/'));
layout += "/layout/ft-layout";
posix_spawn_file_actions_t io;
posix_spawn_file_actions_init(&io);
posix_spawn_file_actions_addopen(&io, 0, "/dev/null", O_RDONLY, 0);
posix_spawn_file_actions_addopen(&io, 1, "/tmp/frametop-layout.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
posix_spawn_file_actions_addopen(&io, 1, logname.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW, 0644);
posix_spawn_file_actions_adddup2(&io, 1, 2);
std::string arg(cmd);
char *argv[] = {layout.data(), arg.data(), nullptr};
pid_t pid; // reaped by the compositor's SIGCHLD handler
if (posix_spawn(&pid, layout.c_str(), &io, nullptr, argv, environ) != 0)
std::printf("can't run %s\n", layout.c_str());
// A log that can't be opened (a symlink or a directory in its place) fails the spawn too.
const int rc = posix_spawn(&pid, layout.c_str(), &io, nullptr, argv, environ);
if (rc != 0)
std::printf("can't run %s (log %s): %s\n", layout.c_str(), logname.c_str(), std::strerror(rc));
posix_spawn_file_actions_destroy(&io);
}
@@ -1954,6 +1960,11 @@ int ft_vr_modifiers(uint32_t format, uint64_t *out, int max) {
bool ft_vr_screens_shown(void) { return g_vr && ModeVisible(); }
bool ft_vr_paused(void) { return g_paused; }
bool ft_vr_screen_visible(int index) {
const auto it = g_screens.find(index);
return !g_vr || (it != g_screens.end() && it->second.visible);
}
enum ft_attention ft_vr_screen_attention(int index) {
const auto it = g_screens.find(index);
return g_vr && it != g_screens.end() ? it->second.attention : FT_FOCUSED;
+2
View File
@@ -39,6 +39,8 @@ int ft_vr_modifiers(uint32_t format, uint64_t *out, int max);
bool ft_vr_screens_shown(void);
// Frametop is paused for a VR game ("pause on"): everything is hidden, and KWin slows down.
bool ft_vr_paused(void);
// Screen (or floating window) `index` shows now. True without SteamVR (--no-vr).
bool ft_vr_screen_visible(int index);
// How much of a screen you see, for its frame rate (compositor.c): hidden (or out of view),
// in view, or focused (you look at it, or a laser or the mouse is on it). Focused without
// SteamVR (--no-vr) or for an unknown screen.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Update the lines of ~/.config/frametop.conf that still read exactly as an older
# frametop.conf.example wrote them. A line you changed stays as it is. install.sh and
# hands/rec/install.sh run this on every install and update.
# HANDS_SWAP_SIDES=0: the example's value until 2026-10-05. It made ft-hands keep ft-camd's side
# camera names even when they're backwards (some SteamVR restarts swap them), so hands landed
# beside their cutouts and the hand recorder labelled the side cameras wrong. auto tells from
# the hands.
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
. "$root/scripts/_env.sh"
on_frame_script <<'EOF'
f=~/.config/frametop.conf
[ -f "$f" ] || exit 0
old="HANDS_SWAP_SIDES=0 # hand tracking (hands/run.sh install): 1 = the side cameras' names are swapped, which some SteamVR restarts cause (hands/tools/check_sides.py --ring tells)"
new="HANDS_SWAP_SIDES=auto # hand tracking: which side camera is which. auto = ft-hands tells from the hands and fixes ft-camd's names, which some SteamVR restarts swap | 0 = keep ft-camd's | 1 = exchange them"
if grep -qxF "$old" "$f"; then
tmp=$(mktemp)
awk -v old="$old" -v new="$new" '$0 == old { print new; next } { print }' "$f" > "$tmp"
cat "$tmp" > "$f" # in place: the file keeps its owner and mode
rm -f "$tmp"
echo "settings: HANDS_SWAP_SIDES=0 (the old default) is now auto: hand tracking tells the side cameras apart itself"
fi
EOF
+1 -1
View File
@@ -90,7 +90,7 @@ section "Gaze service (last 60 lines, podman's left out)"
journalctl --user -u frametop-gaze -n 300 --no-pager -o short 2>/dev/null | grep -v ' podman\[' | tail -n 60
section "Our eye tracker's frame grabber (last 20 lines)"
journalctl -u frametop-eyegrab -n 20 --no-pager -o short 2>/dev/null
for f in /tmp/frametop-session.log /tmp/frametop-screens.log /tmp/frametop-layout.log; do
for f in /tmp/frametop-session.log /tmp/frametop-screens.log "$XDG_RUNTIME_DIR/frametop-layout.log"; do
section "$f (last 60 lines)"
tail -n 60 "$f" 2>/dev/null || echo "missing"
done
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
"$root/scripts/frame.sh" -C screens 'mkdir -p build; gcc -std=c11 -Wall -Wextra -Werror tests/relay-buttons-test.c -o build/relay-buttons-test && build/relay-buttons-test'
+51 -20
View File
@@ -18,7 +18,6 @@ stops, or restarts anything.
"""
import base64
import json
import math
import mmap
import os
import platform
@@ -34,6 +33,8 @@ UID = os.getuid()
KNOWN_GOOD = os.path.join(HOME, ".local/state/frametop/known-good.json")
STEAMVR_BIN = "/opt/steamvr/bin/linuxarm64"
LAUNCHER = os.path.join(HOME, ".local/share/applications/deckard-nested-desktop.desktop")
STOCK_LAUNCHER = "/usr/share/applications/deckard-nested-desktop.desktop"
NATIVE_LAUNCHER = os.path.join(HOME, ".local/share/applications/native-deckard-nested-desktop.desktop")
VRPATHS = os.path.join(HOME, ".config/openvr/openvrpaths.vrpath")
# The Frametop desktop has its own XDG_CONFIG_HOME (session/frametop-session.sh). An install from a
# terminal there, before pointer/driver/install.sh set SteamVR's, left vrpathreg's registry here.
@@ -56,6 +57,7 @@ PACKAGES = {
"the registry stops and comes back after a desktop restart",
"gamescope": "the headset's volume buttons with nothing focused; typing goes where you last clicked",
"bluez": "a Bluetooth mouse reconnecting after it sleeps",
"steamdeck-kde-presets": "Launch a program -> Native Desktop opens SteamOS's own desktop",
}
KERNEL_HINT = "display power (ft-powerd) and hand tracking"
@@ -69,9 +71,11 @@ UNITS = {
"frametop-hands": None,
}
# eye-server.mmap offsets, the same as gaze/ft-gaze.cpp's (packed, little-endian).
EYE_COUNTER, EYE_TIME, EYE_OPEN, EYE_NEED = 0x38, 0x157, 0x1CB, 0x1D3
EYE_VECTORS = (0x15F, 0x16B, 0x19B, 0x1A7) # set 1 left, right; set 2 left, right
# eye-server.mmap offsets, the same as gaze/ft-gaze.cpp's (packed, little-endian), and the
# layouts its EyeFile::Detect knows: everything from the timestamp on moved by a shift.
EYE_COUNTER, EYE_TIME, EYE_NEED = 0x38, 0x157, 0x1F3 + 5
EYE_SET1 = (0x15F, 0x16B) # set 1 left, right
EYE_LAYOUTS = {0: "stable", 5: "the 0.4.x beta's (+5)"}
# Runs in a child process, so a SteamVR that hangs can't hang the check.
OPENVR_PROBE = r"""
@@ -179,8 +183,8 @@ def check_host():
(f"{STEAMVR_BIN}/vrpathreg", "warn", "the pointer driver can't be installed or removed"),
("/usr/share/deckard/mesavars.sh", "warn", "the desktop starts without SteamOS's Mesa settings"),
("/etc/profile.d/flatpak.sh", "warn", "Flatpak apps may open Discover instead of starting"),
("/usr/share/applications/deckard-nested-desktop.desktop", "warn",
"SteamOS's Desktop launcher entry is gone or renamed, so Frametop's copy may not replace it"),
(STOCK_LAUNCHER, "warn", "SteamOS's Desktop launcher entry is gone or renamed, so Frametop's "
"copy may not replace it, and there's no Native Desktop"),
]
missing = [n for n in needed if not os.path.exists(n[0])]
for path, state, effect in missing:
@@ -247,6 +251,16 @@ def check_host():
report("ok", "launcher", f"Desktop starts {session}")
else:
report("FAIL", "launcher", f"Desktop starts {session}, which doesn't exist")
if os.path.exists(NATIVE_LAUNCHER):
try:
same = launcher_keys(NATIVE_LAUNCHER) == launcher_keys(STOCK_LAUNCHER)
except OSError:
same = False
if same:
report("ok", "Native Desktop", "the launcher's copy matches SteamOS's Desktop entry")
else:
report("warn", "Native Desktop", "the launcher's copy no longer matches SteamOS's Desktop entry; "
"./desktops.sh install refreshes it")
if systemctl("is-enabled", "frametop-power") == "enabled":
if os.access(BACKLIGHT, os.W_OK):
@@ -255,6 +269,13 @@ def check_host():
report("FAIL", "backlight", f"{BACKLIGHT} isn't writable, so ft-powerd can't turn the displays off")
def launcher_keys(path):
"""A launcher entry's keys, less the ones desktops.sh changes in its Native Desktop copy."""
with open(path) as f:
pairs = [line.rstrip("\n").split("=", 1) for line in f if "=" in line and not line.startswith("#")]
return {k: v for k, v in pairs if k != "X-Steam-Special" and k.split("[")[0] != "Name"}
def launcher_session():
try:
with open(LAUNCHER) as f:
@@ -461,21 +482,31 @@ def check_eye_tracker():
if len(m) < EYE_NEED:
report("FAIL", "eye tracker", f"{EYE_MMAP} is {len(m)} bytes, smaller than gaze/ft-gaze.cpp reads")
return
def fits(shift, before, now):
"""ft-gaze's test (EyeFile::Detect): the timestamp near the clock and moving on, and
both set-1 directions unit vectors."""
t = struct.unpack_from("<d", m, EYE_TIME + shift)[0]
return now - 2 < t <= now + 2 and t > before[shift] and all(
0.81 < sum(x * x for x in struct.unpack_from("<3f", m, o + shift)) < 1.21 for o in EYE_SET1)
first = struct.unpack_from("<I", m, EYE_COUNTER)[0]
time.sleep(0.3)
if struct.unpack_from("<I", m, EYE_COUNTER)[0] == first:
report("skip", "eye tracker", "idle, so its layout wasn't checked")
return
age = time.clock_gettime(time.CLOCK_MONOTONIC_RAW) - struct.unpack_from("<d", m, EYE_TIME)[0]
units = sum(abs(math.sqrt(sum(x * x for x in struct.unpack_from("<3f", m, o))) - 1) < 0.02
for o in EYE_VECTORS)
# A lost eye can zero its vector, so two of the four are enough. The timestamp is the
# strong check: a fresh CLOCK_MONOTONIC_RAW double doesn't land on that offset by chance.
if abs(age) < 1 and units >= 2:
report("ok", "eye tracker", "eye-server.mmap still has the layout gaze/ft-gaze.cpp reads")
else:
report("FAIL" if gaze else "warn", "eye tracker", f"eye-server.mmap layout changed (sample age "
f"{age:.3g} s, {units} of 4 gaze vectors unit length); update the offsets in gaze/ft-gaze.cpp")
for attempt in range(5): # 1.5 s: a blink or a moment with an eye lost doesn't fail it
before = {shift: struct.unpack_from("<d", m, EYE_TIME + shift)[0] for shift in EYE_LAYOUTS}
time.sleep(0.3)
if attempt == 0 and struct.unpack_from("<I", m, EYE_COUNTER)[0] == first:
report("skip", "eye tracker", "idle, so its layout wasn't checked")
return
now = time.clock_gettime(time.CLOCK_MONOTONIC_RAW)
for shift, name in EYE_LAYOUTS.items():
if fits(shift, before, now):
report("ok", "eye tracker", f"eye-server.mmap has a layout gaze/ft-gaze.cpp knows: {name}")
return
age = now - struct.unpack_from("<d", m, EYE_TIME)[0]
report("FAIL" if gaze else "warn", "eye tracker", f"eye-server.mmap has none of the layouts gaze/ft-gaze.cpp "
f"knows (sample age {age:.3g} s at the stable offset), so SteamVR's eye tracking can't be used: add the "
"new one to EyeFile::Detect. Right after the headset goes on its tracker warms up for about 20 s: "
"check again after that")
def main():
+37 -4
View File
@@ -12,8 +12,10 @@ dropped, was lost until the config was deleted.
The session runs this before Plasma starts, so nothing races Plasma for the file. Each
panel numbered at or past the screen count moves to screen 0, with its system tray's
containment, and keeps its widgets and settings. A panel is left where it is when screen
0 already has a panel on that edge: it comes back by itself if the screens do. The file
is backed up once per repair (<file>.ft-bak), and the changes go through kwriteconfig6.
0 already has a panel on that edge: it comes back by itself if the screens do. Before
each repair the file is backed up to <file>.ft-bak.last, and <file>.ft-bak keeps it as it
was before the first repair (a later repair never overwrites that one). The changes go
through kwriteconfig6.
fix-panels.py [--screens N] [--file APPLETSRC] [--check]
--screens the desktop's screen count (default: the configured layout's)
@@ -97,6 +99,24 @@ def default_screens():
return ft_layout.screen_count()
def backup(path, dst):
"""Copy path to dst whole or not at all. The copy goes to dst.tmp, reaches the disk, and
only then takes dst's name, so a copy cut off partway (a full disk, a crash, the battery)
never leaves a short dst behind, and a dst that's there already stays as it was."""
tmp = dst + ".tmp"
try:
shutil.copy2(path, tmp)
with open(tmp, "rb") as f:
os.fsync(f.fileno())
os.replace(tmp, dst)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
def main(argv):
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--screens", type=int)
@@ -125,14 +145,27 @@ def main(argv):
print(f"{screens} screen(s)")
return 1 if moves or kept else 0
bak = path + ".ft-bak"
if moves:
shutil.copy2(path, path + ".ft-bak")
try:
if not os.path.exists(bak):
# The config as it was before the first repair: a later run never overwrites
# it with an already-repaired state.
backup(path, bak)
# The config as it was just before this repair, so this one can be undone too,
# keeping what changed since the first (a moved panel's old screen is only here).
backup(path, bak + ".last")
except OSError as e:
# No repair without a backup; the next start tries again.
print(f"frametop: couldn't back up {path} ({e}); left the panels as they are", file=sys.stderr)
return 1
for pid, was, ids in moves:
for cid in ids:
subprocess.run(["kwriteconfig6", "--file", os.path.abspath(path), "--group", "Containments",
"--group", cid, "--key", "lastScreen", "0"], check=True)
print(f"frametop: panel {pid} was saved on screen {was}, which this desktop doesn't have "
f"({screens} screen(s)); moved it to the first screen (backup: {path}.ft-bak)", file=sys.stderr)
f"({screens} screen(s)); moved it to the first screen (backups: {bak}.last from before this "
f"repair, {bak} from before the first)", file=sys.stderr)
for pid, was, why in kept:
print(f"frametop: panel {pid} is saved on screen {was}, which this desktop doesn't have; "
f"left there: {why}", file=sys.stderr)
+2 -1
View File
@@ -83,7 +83,8 @@ if [ "${1:-}" != --inner ]; then
# Arrange the screens once they're up: in the profile this desktop starts with (FT_PROFILE,
# from a profile's launcher entry, or the default profile), which also opens its apps, or
# else in the saved layout (skipped when auto-arrange is off). docs/profiles.md.
setsid "$here/../layout/ft-layout" start --wait 90 > /tmp/frametop-layout.log 2>&1 < /dev/null &
# The log starts fresh here; ft-screens appends its later ft-layout runs to it.
setsid "$here/../layout/ft-layout" start --wait 90 > "$XDG_RUNTIME_DIR/frametop-layout.log" 2>&1 < /dev/null &
if [ "$backend" = gamescope ]; then
export ENABLE_GAMESCOPE_WSI=1 GAMESCOPE_MANGOAPP_SOCKET_DISABLE=1
+1 -1
View File
@@ -40,7 +40,7 @@ POINTER_HEAD_DEADZONE=0.5 # keyboard clicks at the gaze (Meta+J, Meta+K): degre
POINTER_KEY_TAP=0.25 # keyboard clicks: let go within this (s) and it clicks where the dot was at the press, and tells the gaze tracker it was right
GAZE_TRACKER=auto # gaze service: auto = our own eye tracker when it's installed (gaze/tracker/install.sh, which install.sh offers), else SteamVR's | own = ours | steam = SteamVR's. Each keeps its own calibration (Calibrate on Frametop Input Settings' Gaze page)
GAZE_EYE=auto # gaze service: eye bias. auto = each eye weighted by how far off it was at your recent nudges | left | right = that eye counts twice
HANDS_SWAP_SIDES=0 # hand tracking (hands/run.sh install): 1 = the side cameras' names are swapped, which some SteamVR restarts cause (hands/tools/check_sides.py --ring tells)
HANDS_SWAP_SIDES=auto # hand tracking: which side camera is which. auto = ft-hands tells from the hands and fixes ft-camd's names, which some SteamVR restarts swap | 0 = keep ft-camd's | 1 = exchange them
HANDS_CPUS=5,6,7 # hand tracking: the CPUs its model threads run on
HANDS_CAMERAS=auto # hand tracking: auto (by the light) | mono (the IR cameras; also keeps ft-camd off the colour ones) | color | all
HANDS_BRIGHT=all # hand tracking, auto: the cameras in bright light, all | color
+65 -4
View File
@@ -7,6 +7,7 @@ kwriteconfig6 on a copy. Nothing here touches the running desktop or its config.
"""
import importlib.util
import os
import resource
import subprocess
import sys
import tempfile
@@ -143,13 +144,15 @@ class Run(unittest.TestCase):
self.assertEqual(groups[("Containments", "11")]["lastScreen"], "0")
self.assertEqual(groups[("Containments", "10", "Applets", "101")]["plugin"], "org.kde.plasma.kickoff")
self.assertEqual(groups[("Containments", "25")]["lastScreen"], "8") # a spare's desktop
with open(self.path + ".ft-bak") as f:
self.assertEqual(f.read(), ISSUE_18)
# A second run finds nothing to do and leaves the backup alone.
for bak in (".ft-bak", ".ft-bak.last"):
with open(self.path + bak) as f:
self.assertEqual(f.read(), ISSUE_18, bak)
# A second run finds nothing to do and makes no backup.
os.remove(self.path + ".ft-bak")
os.remove(self.path + ".ft-bak.last")
r = self.run_script()
self.assertEqual((r.returncode, r.stderr), (0, ""))
self.assertFalse(os.path.exists(self.path + ".ft-bak"))
self.assertEqual(os.listdir(self.dir.name), [os.path.basename(self.path)])
self.assertEqual(self.run_script("--check").returncode, 0)
def test_no_config_yet(self):
@@ -157,6 +160,64 @@ class Run(unittest.TestCase):
self.assertEqual(self.run_script().returncode, 0)
self.assertEqual(self.run_script("--check").returncode, 0)
def test_backup_is_written_once(self):
"""A backup that's already there, the config before an earlier repair, stays as it
was, and the repair still goes ahead."""
with open(self.path + ".ft-bak", "w") as f:
f.write("the config before an earlier repair\n")
r = self.run_script()
self.assertEqual(r.returncode, 0, r.stderr)
with open(self.path + ".ft-bak") as f:
self.assertEqual(f.read(), "the config before an earlier repair\n")
with open(self.path) as f:
groups = fp.parse(f.read())
self.assertEqual(groups[("Containments", "10")]["lastScreen"], "0")
self.assertEqual(groups[("Containments", "11")]["lastScreen"], "0")
def test_last_backup_is_from_before_each_repair(self):
"""<file>.ft-bak.last is the config as it was just before the latest repair, so that
repair can be undone without losing what changed since the first; <file>.ft-bak stays
the config before the first repair. The log names both."""
bak = self.path + ".ft-bak"
r = self.run_script()
self.assertEqual(r.returncode, 0, r.stderr)
# The panel is lost again later, after a change of the user's own (another widget).
changed = ISSUE_18.replace("org.kde.plasma.kickoff", "org.kde.plasma.trash")
with open(self.path, "w") as f:
f.write(changed)
r = self.run_script()
self.assertEqual(r.returncode, 0, r.stderr)
with open(bak + ".last") as f:
self.assertEqual(f.read(), changed)
with open(bak) as f:
self.assertEqual(f.read(), ISSUE_18)
with open(self.path) as f:
self.assertEqual(fp.parse(f.read())[("Containments", "10")]["lastScreen"], "0")
self.assertIn(bak + ".last ", r.stderr)
self.assertIn(bak + " ", r.stderr)
def test_failed_backup_leaves_nothing(self):
"""A backup cut off partway (a full disk; here a 1 KiB limit on file size) leaves no
backup and no temporary file behind, and the panels aren't touched without one. The
next start, with room, makes the backup and repairs."""
self.assertGreater(len(ISSUE_18), 1024)
def small_files():
resource.setrlimit(resource.RLIMIT_FSIZE, (1024, 1024))
r = subprocess.run([sys.executable, SCRIPT, "--file", self.path, "--screens", "3"],
capture_output=True, text=True, preexec_fn=small_files)
self.assertEqual(os.listdir(self.dir.name), [os.path.basename(self.path)])
self.assertEqual(r.returncode, 1, r.stderr)
self.assertIn("couldn't back up", r.stderr)
with open(self.path) as f:
self.assertEqual(f.read(), ISSUE_18)
r = self.run_script()
self.assertEqual(r.returncode, 0, r.stderr)
with open(self.path + ".ft-bak") as f:
self.assertEqual(f.read(), ISSUE_18)
with open(self.path) as f:
self.assertEqual(fp.parse(f.read())[("Containments", "10")]["lastScreen"], "0")
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -37,6 +37,7 @@ fi
# The VNC password is limited to 8 characters by the protocol. The traffic is
# still encrypted by the tailnet (WireGuard).
mkdir -m 0700 -p "$creds" # remote-desktop.sh normally creates it; we may get here first
if [ ! -s "$creds/vnc-password" ]; then
(umask 077; head -c 12 /dev/urandom | base64 | tr -d '/+=' | cut -c1-8 > "$creds/vnc-password")
fi
+21 -6
View File
@@ -10,9 +10,10 @@
# It never stops what you're using now: the input relay carries the keyboard and mouse, and the
# desktop runs from the repo. So it goes in two steps:
# 1. Frametop stops starting. The launcher's Desktop entry goes back to the stock desktop, and
# Frametop's services, SteamVR driver, menu entries, and system files (our eye tracker's
# frame grabber and the Bluetooth fixes, with sudo) are removed. What runs now keeps running
# until you restart the headset.
# Frametop's Native Desktop entry, services, SteamVR driver, menu entries, and system files
# (our eye tracker's frame grabber and the Bluetooth fixes, with sudo) are removed, and so
# are the file capabilities of hand tracking's camera broker (ft-camd, with sudo). What runs
# now keeps running until you restart the headset.
# 2. After the restart, run it again. It deletes the code (~/frametop) and, if you want, your
# settings and the build container.
# When nothing of Frametop is running, one run does both.
@@ -33,6 +34,7 @@ EOF
dry=0
apps=$HOME/.local/share/applications
override=$apps/deckard-nested-desktop.desktop
native_copy=$apps/native-deckard-nested-desktop.desktop
relay_unit=$HOME/.config/systemd/user/frametop-input-relay.service
driver=$HOME/.local/share/frametop/ft_pointer
vrpathreg=/opt/steamvr/bin/linuxarm64/vrpathreg
@@ -102,9 +104,12 @@ main() {
fi
[ "$dry" = 1 ] && echo "Dry run: nothing changes."
repo=$(find_repo)
camd=$repo/hands/build/ft-camd
camd_caps=$(getcap "$camd" 2>/dev/null || true)
# Step 1: what makes Frametop start. Nothing here stops a running program.
[ -f "$override" ] && grep -q 'Frametop' "$override" || override=
[ -f "$native_copy" ] || native_copy=
units=("$HOME"/.config/systemd/user/frametop-*.service)
for f in ft-input-settings ft-display-settings ft-layout-reset ft-screens-toggle ft-remote-settings ft-gazeprobe; do
[ -e "$apps/$f.desktop" ] && entries+=("$apps/$f.desktop")
@@ -114,15 +119,17 @@ main() {
[ -L "$handsctl" ] || handsctl=
exists "${eyegrab_files[@]}" "${bt_files[@]}" && sys=1
if [ -n "$override" ] || [ ${#units[@]} -gt 0 ] || [ ${#entries[@]} -gt 0 ] || [ -d "$driver" ] ||
[ -n "$handsctl" ] || [ "$sys" = 1 ]; then
if [ -n "$override" ] || [ -n "$native_copy" ] || [ ${#units[@]} -gt 0 ] || [ ${#entries[@]} -gt 0 ] ||
[ -d "$driver" ] || [ -n "$handsctl" ] || [ "$sys" = 1 ] || [ -n "$camd_caps" ]; then
step "Step 1 of 2: stop Frametop from starting"
echo "This removes:"
[ -n "$override" ] && echo " - the launcher's Desktop entry (Launch a program -> Desktop opens the stock desktop again)"
[ -n "$native_copy" ] && echo " - the launcher's Native Desktop entry (Desktop opens the same stock desktop then)"
for f in "${units[@]}"; do echo " - the service $(basename "$f")"; done
[ -d "$driver" ] && echo " - the 3D mouse's SteamVR driver (ft_pointer)"
[ ${#entries[@]} -gt 0 ] && echo " - ${#entries[@]} menu entries (Frametop Display Settings, Input Settings, ...)"
[ -n "$handsctl" ] && echo " - $handsctl"
[ -n "$camd_caps" ] && echo " - the hand camera broker's file capabilities (needs your password)"
exists "${eyegrab_files[@]}" && echo " - our eye tracker's frame grabber (a system service: needs your password)"
exists "${bt_files[@]}" && echo " - the Bluetooth fixes (system files: needs your password)"
echo "What runs now keeps running until you restart the headset, so your keyboard, mouse, and"
@@ -130,6 +137,7 @@ main() {
ask "Uninstall Frametop?" n || { echo "Nothing changed."; return 0; }
[ -n "$override" ] && run rm -f "$override"
[ -n "$native_copy" ] && run rm -f "$native_copy"
if [ ${#units[@]} -gt 0 ]; then
for f in "${units[@]}"; do names+=("$(basename "$f")"); done
run systemctl --user disable "${names[@]}" 2>/dev/null || true
@@ -147,13 +155,20 @@ main() {
[ -n "$handsctl" ] && run rm -f "$handsctl"
if [ "$sys" = 1 ]; then
echo "The system files need your password (sudo)."
# $1 is ft-camd when it has capabilities to remove, else empty; the system files follow.
if ! run sudo bash -c '
camd=$1; shift
for u in frametop-eyegrab steamframe-bt-fixups; do systemctl disable $u.service 2>/dev/null; done
rm -f "$@"
rmdir /etc/frametop /etc/steamframe /etc/systemd/system/bluetooth.service.d 2>/dev/null
systemctl daemon-reload; true' sys "${eyegrab_files[@]}" "${bt_files[@]}"; then
[ -n "$camd" ] && [ -x "$camd" ] && setcap -r "$camd" 2>/dev/null
systemctl daemon-reload; true' sys "${camd_caps:+$camd}" "${eyegrab_files[@]}" "${bt_files[@]}"; then
echo "warning: the system files weren't removed (no password?). Run this again to retry." >&2
fi
elif [ -n "$camd_caps" ]; then
echo "ft-camd's file capabilities need your password (sudo) to remove."
run sudo setcap -r "$camd" ||
echo "warning: ft-camd keeps its capabilities (no password?). Run this again to retry." >&2
fi
[ "$dry" = 1 ] || echo "Frametop no longer starts."
fi