mirror of
https://github.com/DeeJanuz/frametop.git
synced 2026-10-06 22:00:11 +02:00
Compare commits
65
Commits
v0.2.1
...
experimental
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b11162b61 | ||
|
|
e2f51477a3 | ||
|
|
4ba49af487 | ||
|
|
82fbc6fc1b | ||
|
|
8bd2ac4c92 | ||
|
|
ac692c38ea | ||
|
|
d6556ca813 | ||
|
|
9d66e36439 | ||
|
|
dac4188392 | ||
|
|
a9f1bcfdde | ||
|
|
effcfd0e48 | ||
|
|
3be79efade | ||
|
|
030ef2faf1 | ||
|
|
44c0b2d796 | ||
|
|
56aa3e4b4b | ||
|
|
977e9a0950 | ||
|
|
82979e4423 | ||
|
|
c119442c70 | ||
|
|
fb1c587d69 | ||
|
|
102e859cf8 | ||
|
|
b21dfd1bb0 | ||
|
|
bcf8516ebd | ||
|
|
db0d526f81 | ||
|
|
81f24a5d04 | ||
|
|
55f00f300d | ||
|
|
b2d32e8934 | ||
|
|
f6b4323669 | ||
|
|
903abd75dd | ||
|
|
f65afce59a | ||
|
|
1bb8e73cb9 | ||
|
|
0821b1013e | ||
|
|
3cccad3525 | ||
|
|
845bff0037 | ||
|
|
2ede132b62 | ||
|
|
a70463d95b | ||
|
|
a84889b4a5 | ||
|
|
8b9edcfbe2 | ||
|
|
0f1132ed27 | ||
|
|
4828dd46b5 | ||
|
|
d4e5e2b442 | ||
|
|
82da3525b7 | ||
|
|
2a2bca336c | ||
|
|
cd47dde566 | ||
|
|
a5d001d4b5 | ||
|
|
fa65954834 | ||
|
|
c747bea791 | ||
|
|
e918359c8f | ||
|
|
c53a4f07e9 | ||
|
|
cb34ba45a1 | ||
|
|
740aededff | ||
|
|
8655395030 | ||
|
|
6f65145553 | ||
|
|
cce37b5835 | ||
|
|
ff3724cb3c | ||
|
|
48bceaef64 | ||
|
|
c1ba71aba1 | ||
|
|
9c6647538b | ||
|
|
8a77364767 | ||
|
|
b61b4d6c6a | ||
|
|
edf605f896 | ||
|
|
b5fe574ae6 | ||
|
|
76d6542071 | ||
|
|
a94a0fb432 | ||
|
|
b1b1a16f5b | ||
|
|
66160aed55 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Executable
+16
@@ -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'
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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)."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Executable
+139
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Executable
+25
@@ -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
@@ -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
|
||||
|
||||
Executable
+4
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user