Compare commits

..
20 Commits
Author SHA1 Message Date
DeeJanuz b67c973f64 Merge branch perf-gaze into perf 2026-10-03 09:28:44 -06:00
DeeJanuzandClaude Opus 5.5 99aec84f62 Pointer: ft-screens announces new panels to the helper
The helper now reads SteamVR's list of panels every 20 s instead of every second, so a panel
made in between (a floating window's menu, frametop.float.N.sub.K, or the Frametop keyboard
the first time it opens) couldn't be clicked with the mouse until the next read. ft-screens
now sends "overlay <key>" to @ft_pointer_helper right after it makes one, and the helper adds
it to its list at once (only frametop.* keys). An older helper ignores it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:28:10 -06:00
DeeJanuzandClaude Opus 5.5 e44837cee6 Gaze: the hidden panel waits for a command instead of waking every 50 ms
The calibration panel runs for as long as the gaze service does, hidden nearly all the time,
and it woke 20 to 30 times a second to look at its socket and SteamVR's events: about 0.9% of
a core, the main cost left with gaze idle.

It now waits in poll() on its command socket: up to a second while hidden, and up to 10 ms
while shown, as before (it still drains SteamVR's events each pass, so a quit is acknowledged
within a second while hidden). A command wakes it at once, so "show" draws sooner than
before. With --watch-stdin, its stdin closing wakes it as well, so stopping it doesn't wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:28:02 -06:00
DeeJanuzandClaude Opus 5.5 95c5d056cf Gaze: ft-gaze's loop runs every 4 ms instead of 2
ft-gaze's loop slept 2 ms, so 500 times a second it read the head pose
(GetDeviceToAbsoluteTrackingPose), checked the eye tracker's counter, and drained SteamVR's
events, for samples that come 90 times a second.

It now sleeps 4 ms. A new sample is printed within 4 ms of appearing, 2 on average (was 1),
and the pose history still has a pose within 2 ms of any sample's time, which keeps the
head-pose error under 0.2 degrees for a head turning 100 degrees a second. Sleeping until
the next sample is due would have thinned the pose history to 11 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:26:54 -06:00
DeeJanuz e6a931f7ee Merge branch perf-pointer into perf 2026-10-03 09:26:18 -06:00
DeeJanuzandClaude Opus 5.5 0c95d1d731 Gaze: ft-gaze prints and reads only the sources in use
ft-gaze computed and printed all six sources for every sample: about 1.3 KB of JSON a line
with our tracker (practice2), 120 KB a second through podman's stdio relay for ft-gazed to
json.loads 90 times a second. It also read SteamVR's gaze action for every sample outside
games (UpdateActionState and GetEyeTrackingDataRelativeToNow, two calls into vrserver, 180 a
second), though with our tracker ft-gazed only uses own and mmap1.

- ft-gaze takes --sources LIST (action, mmap1, mmap2, left, right, own, and eye for the EYE
  object; all by default, so the probe and ft-eyes-session are unchanged), and with
  --watch-stdin a line "sources LIST" on stdin switches them. A source left out isn't read
  and prints as {"ok":0} ("eye" as null), so every line keeps the same keys. An older
  ft-gaze ignores both, and prints everything as before.
- ft-gazed asks for what it reads: own,mmap1 with our tracker; left,right,mmap1 with
  SteamVR's eyes; the source plus mmap1 and mmap2 on the older one-source path. While a
  check or the calibration runs or waits to open, all of them, since checks record every
  source (the calibration fits the action's correction too) and the fit check reads "eye".
  It switches as soon as that changes, well inside the check's 0.45 s settle.
- So the action is read only during checks, or with --source action.

On recorded samples, a line with own and mmap1 is about 700 bytes instead of 1,300
(practice2), and one with left, right and mmap1 about 550 instead of 940 (test1).
gaze/test/idle-test.py now checks that ft-gaze starts with every source for a check and is
then switched to those in use, without the action or own; all its checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:23:53 -06:00
DeeJanuz d6930a61f5 Merge branch perf-session into perf 2026-10-03 09:22:07 -06:00
DeeJanuz 155deada52 Merge branch perf-screens into perf 2026-10-03 09:22:07 -06:00
DeeJanuzandClaude Opus 5.5 33fb3bb611 update-check: KWin's blur and contrast effect ids, retest hints
The session now turns KWin's blur and contrast effects off by id (blurEnabled and
contrastEnabled in the desktop's kwinrc), and a KWin that renamed them would quietly
leave them on. The check looks for their built-in factories (KWin::blur_factory,
KWin::contrast_factory) in kwin_wayland, which it already reads for --output-count,
and warns if one is gone. The kwin and plasma-workspace retest hints gain the blur and
the hidden autostart entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:21:09 -06:00
DeeJanuzandClaude Opus 5.5 6cae2e0afe Remote Access: check the status every 5 s instead of every 2 s
While its window was open, Frametop Remote Access ran remote-ctl.sh status every 2 s,
and each run spawns bash, curl (the tailnet name from tailscaled) and python3 to parse
it. It now checks every 5 s, plus when the window comes to the front and once more 2 s
after turning remote access on or off or changing the password, so a change still
shows within a couple of seconds. A check doesn't start while one is still running.
Doing the check in-process would duplicate remote-ctl.sh's idea of "running", which
the session and the pause code share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:20:32 -06:00
DeeJanuzandClaude Opus 5.5 060b4957f8 Eye tracker: ft-eyegrab checks only the slot each camera writes next
While copying, ft-eyegrab woke every 300 us (about 1,500 to 3,000 times a second) and
fingerprinted all eight slots each time: 8 x 256 strided reads from DMA-BUF memory.

- Each look now checks only the slot each camera writes next. The order is known (camera 0
  3,0,1,2; camera 1 7,5,4,6,5,7,6,4), and the next slot follows from the last two; the table
  starts from those orders and learns from every frame, so a SteamVR update that changes
  them costs a few seconds of full scans, not frames.
- A camera with nothing in its expected slot 1.5 frames after its last one, or with no
  order yet, gets all four slots checked, as before. A frame that turns up in an unexpected
  slot means full scans for that camera for 2 s.
- A slot's fingerprint is taken again when it stops being one of the two in use, so a later
  check sees only a new frame. A frame is still passed on when its camera starts the frame
  after next.
- Between frames it sleeps until 2.5 ms before the next is due, then looks every 1 ms, with
  0.5 ms of timer slack (PR_SET_TIMERSLACK, --share only). With no frames from either
  camera for 0.5 s (headset off) it looks every 4 ms.
- --rec keeps its 0.3 ms polls (and the expected-slot checks), for its timestamps.

Tested offline by building poll_frames against simulated cameras that write each frame in
four bursts, the last after the next frame starts (6 s, both cameras): 1,076 frames passed
on, none torn or skipped, with the known orders and with camera 1 in a different order.
Wakeups 1,486/s -> 207/s, the poller's CPU 3.6% -> 0.8% of a core (in plain memory; the real
DMA-BUF reads cost more), and a frame's start is seen 1.35 ms after it begins on average
instead of 0.76. With a camera stalling 15 ms every 2 s, the old poller passed on 22 torn
frames and the new one 8 or fewer.

Built (glibc 2.38 symbols at most, the host has 2.39), not installed: it runs as root from
/etc/frametop, so it takes effect only after gaze/tracker/install.sh (sudo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:20:00 -06:00
DeeJanuzandClaude Opus 5.5 57617d4a14 ft-powerd: ask SteamVR every 100 ms, not on every input event
The loop polled the input devices with a 100 ms timeout and then, on every wake, did
SteamVR's part: PollNextEvent, the headset's activity level and every device's pose, all
IPC calls to vrserver. Input wakes it at once so the displays come on with the first
key or motion, but a moving mouse sends hundreds of events a second, so moving the mouse
meant hundreds of rounds of IPC a second instead of 10.

Every wake still drains the input devices and the control socket and counts input as
use straight away; SteamVR's part, and the backlight read that goes with it, now run
only when 100 ms have passed since the last time, and poll sleeps until then. Built in
the dev container (power/build.sh, no warnings); not run, since the live ft-powerd holds
@ft_powerd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:19:49 -06:00
DeeJanuzandClaude Opus 5.5 b7dfe4759e Session: don't autostart Discover's notifier or IBus in the desktop
The nested Plasma session runs the system's XDG autostart entries. Discover's update
notifier (/etc/xdg/autostart/org.kde.discover.notifier.desktop) started
plasma-discover --mode update inside it, 520-620 MB resident and about 9% of a core,
with flatpak-system-helper and AppStream downloads behind it. IBus started a nested
ibus-daemon with kimpanel and ibus-extension-gtk3, which no app in the desktop can use:
KWin's input method is ft-textinput (zwp_input_method_v1, focus reports only; the VR
keyboard types through ft-screens' seat), and the session already drops QT_IM_MODULE,
GTK_IM_MODULE and XMODIFIERS. Nothing in Frametop talks to IBus.

Before Plasma starts, the session script copies both entries into
$XDG_CONFIG_HOME/autostart with Hidden=true, which plasma-session honours for that
desktop only. It does this once ([Defaults] autostart=1 in frametoprc) and skips a name
the user already has a file for, so deleting the copy brings the program back. The
geoclue demo agent stays (it answers apps' location requests outside GNOME and idles at
0%), and orca's entry is OnlyShowIn GNOME-family desktops, so it never ran. Tested
against a temporary XDG_CONFIG_HOME, including an existing user ibus.desktop left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:18:31 -06:00
DeeJanuzandClaude Opus 5.5 5b6cfcd746 Session: blur, background contrast and animations off by default
The nested kwinrc had no [Plugins] group, so KWin ran its default blur and background
contrast effects, and kdeglobals had no AnimationDurationFactor, so animations ran at
full length. KWin renders through zink on Turnip, on the GPU vrcompositor needs, and
blur re-renders what's behind every translucent panel and menu; each animation frame is
another frame for KWin and ft-screens.

Before KWin starts, the session script now writes [Plugins] blurEnabled=false and
contrastEnabled=false to $XDG_CONFIG_HOME/kwinrc and [KDE] AnimationDurationFactor=0 to
its kdeglobals, each only if the desktop's own file has no value for it. It does this
once and records that in $XDG_CONFIG_HOME/frametoprc ([Defaults] effects=1), because
System Settings deletes a key put back to its default: without the marker, turning blur
back on wouldn't survive a restart. The ids blur and contrast are the built-in effects
of KWin 6.2.5 on SteamOS (both enabled by default in its plugin metadata). Tested
against a temporary XDG_CONFIG_HOME: fresh config, an existing blurEnabled=true kept,
and a deleted key not rewritten.

README and docs/reference.md say how to turn them back on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:17:44 -06:00
DeeJanuzandClaude Opus 5.5 d8c2ed0c58 Screens: frame rates by attention, ticks in step with the display
ft-screens gave KWin a frame callback for every committed screen on each tick, and the tick
was an 11 ms timer set again after each run, so it slid through the display's frame and came
about 85 times a second at 90 Hz: the desktop repeated a frame several times a second (judder
in scrolling and video), and KWin drew every screen in one burst at a random point of
vrcompositor's frame. Hidden screens got the same 90 Hz unless Frametop was paused for a game.

- Ticks run on a timerfd at absolute times, once per display frame, 1 ms after the vsync
  (IVRSystem::GetTimeSinceLastVsync and the HMD's display frequency, read once a second), so
  KWin gets its callbacks early in the frame. Measured with --no-vr: 91 wakeups a second
  instead of about 85. On the Frame the vsync times SteamVR reports lie on a 90 Hz grid.
- Each screen's callbacks come at a rate for how much of it you see (vr.cpp,
  UpdateAttention): every frame while focused (within 12 degrees of where your head points,
  a laser or the mouse on it in the last 1.5 s, carried, or typed on), 15 a second for the
  rest of what you see (within 60 degrees), and 1 a second when hidden, behind you, or
  paused. Levels rise at once and fall after 1.5 s (focused) or 0.5 s (in view). KWin draws
  a screen only after its callback and its apps wait for theirs, so this throttles the apps
  too. A screen where nothing changes costs nothing at any rate, as before.
- A video in view keeps every frame: 8 commits in a row that each redraw 6% or more of the
  screen, at 10 a second or more, count as one (from the surface's buffer damage).
- "rates F V H" / --rates set the three rates (default 0 15 1, 0 = every frame), "rates?"
  shows them and each screen's level, "watch S" gives everything full rate for S seconds for
  a remote viewer (vnc-bridge.sh renews it), and "phase MS" moves the ticks for tuning.
- ft-screens' main thread runs at nice -5 after the session starts: SteamOS allows down to
  -8 once the soft RLIMIT_NICE is raised, and KWin waits on these ticks. It had spent nearly
  3 times as long waiting to run as running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:16:46 -06:00
DeeJanuzandClaude Opus 5.5 7851ce90cc Eye tracker: ft-eyes sleeps until the next frame is due
ft-eyes looked for new frames about 1,000 times a second: each pass of its loop asked the
control socket with a non-blocking recvfrom (a BlockingIOError nearly every time), read
both cameras' counters, and slept 1 ms. The frames come every 11.1 ms per camera, and only
as counters in ft-eyegrab's shared memory, so there's no fd to wait on.

Now each pass ends in select() on the control socket, with a timeout until 2 ms before the
next frame of either camera is due (from when its last one was seen), then every 1 ms until
it comes. A command wakes it at once. A camera with no frame for 0.1 s (headset off,
grabber idle) isn't waited for, and with both stopped it looks every 20 ms. Waiting for the
frame grabber's file uses the same select, 0.2 s at a time, instead of sleeping through
commands.

A new frame is still seen within about 1 ms of when it lands. On a synthetic share at 90 Hz
per camera, on a heavily loaded headset (load average 23, so ft-eyes rarely sat idle), its
waits went from 178 to 81 a second; unloaded, the old loop's 1 ms sleeps add up to about
1,000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:15:27 -06:00
DeeJanuzandClaude Opus 5.5 e1ee5ef395 Remote desktop: read the layout only after it changes
While FreeRDP ran, vnc-bridge.sh called ft-layout remote-view every 5 s, which scans
all of /proc for plasmashell and runs kscreen-doctor -j: about 4.4% of a core for a
layout that rarely changes.

It now stats the two files the answer depends on, the nested KWin's
~/.config/frametop/kwinoutputconfig.json (positions, scales, primary) and
~/.config/frametop-layout.json (screen sizes), once a second while FreeRDP runs. After
either changes it reads the layout every 2 s for 10 s, since KWin's outputs follow the
file a few seconds later; otherwise once a minute, in case a change touched neither.
With no VNC viewer connected nothing runs (previous commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:14:43 -06:00
DeeJanuzandClaude Opus 5.5 3e7248a04e Remote desktop: connect FreeRDP only while a VNC viewer is connected
vnc-bridge.sh kept FreeRDP connected to krdpserver from the moment remote desktop
started, so krdp captured and H.264-encoded every KWin redraw in software (openh264)
with nobody watching: krdpserver 55-78% of a core, xfreerdp 16-27%, Xvnc 6-11%, with 0
clients on :5900. krdp 6.7 creates its screencast session per RDP connection and drops
it when the connection closes, so krdpserver itself idles without one and stays up.

The bridge now counts established connections to Xvnc's port with ss, starts FreeRDP
when a viewer appears (the desktop shows about 3 s later; the VNC screen is black until
then) and stops it 45 s after the last one leaves (VNC_IDLE_SEC). Xvnc has no client
hook, so its log output, which it writes for every connection, wakes the bridge early;
otherwise it looks every 5 s while idle (0.1% of a core measured, against 0.9% for ss
once a second) and every second while FreeRDP runs. The layout check runs only while
FreeRDP runs.

While a viewer is connected the bridge sends "watch 15" to ft-screens (@ft_screens) at
once and every 5 s, so screens at a reduced frame rate (out of view, headset on a
stand) stream at full rate; it lapses by itself if the bridge dies, and an ft-screens
without the command just answers an error. The window search after starting FreeRDP
now ends when FreeRDP exits instead of polling for 30 s.

krdp on 127.0.0.1 with a fresh password, VNC on the tailnet address with VncAuth, and
remote-ctl.sh start/stop (pause and resume) are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:14:25 -06:00
DeeJanuzandClaude Opus 5.5 a11cef49ba Gaze: ft-eyes and ft-gaze below SteamVR's priority
ft-eyes and ft-gaze run in the dev container through distrobox, so they live in podman's
libpod scope: frametop-gaze.service's limits never reach them, and they ran at nice 0
next to vrcompositor and vrserver, also at nice 0.

- ft-eyes sets itself to nice 10 and SCHED_BATCH at start, before its threads. Batch
  turns off wakeup preemption, so a frame ft-eyes wakes up for can wait out a running
  compositor's turn; a few ms late costs the gaze little.
- ft-gaze sets nice 5 before its threads start, but stays SCHED_OTHER: each sample goes
  on to the pointer, and batch would add the same wait to every one of them.
- Both only ever lower their priority (a higher nice already set wins), and a failure
  is logged and ignored. Checked in the dev container: nice 0 -> 10, policy 0 -> 3
  (SCHED_BATCH) without any capability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:11:15 -06:00
DeeJanuzandClaude Opus 5.5 8a02e41b21 Eye tracker: one thread for OpenCV and numpy
Nothing called cv2.setNumThreads, so OpenCV kept a pool of one worker per core for
pupil windows of 140 to 240 px. Live, its idle workers spun and yielded about 14,000
times a second each, about a quarter of a core, next to SteamVR's compositor. numpy's
OpenBLAS also started 8 threads that never had work.

eyes_pupil.py now sets OpenCV to one thread, and ft-eyes sets OPENBLAS_NUM_THREADS and
OMP_NUM_THREADS to 1 before numpy loads (a value already in the environment wins).

Replaying fit1 into a scratch share (ft-eyes-replay, 14 s measured, capped at one core
with the replay): threads 13 -> 1, involuntary context switches 3,812/s -> 430/s,
system time 6.9% -> 1.6% of a core. Under that cap the frames it kept up with went from
21-36 to 57-69 a second per eye.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 09:08:16 -06:00
22 changed files with 766 additions and 76 deletions

No files matched your search

+2 -1
View File
@@ -144,7 +144,8 @@ This is an early release, tested on one Steam Frame (SteamOS 0.3.0 build 2026092
- Dragging something from one panel to another (a screen and a floating window) works, but the dragged item's icon doesn't show while the pointer is between panels. - Dragging something from one panel to another (a screen and a floating window) works, but the dragged item's icon doesn't show while the pointer is between panels.
- Gaze mode is only as good as its calibration, and that depends on how the headset sits on your face. If the pointer lands off after you adjust the headset, run Quick check or Calibrate on the Gaze page of Frametop Input Settings. - Gaze mode is only as good as its calibration, and that depends on how the headset sits on your face. If the pointer lands off after you adjust the headset, run Quick check or Calibrate on the Gaze page of Frametop Input Settings.
- On SteamVR's Settings page, the 3D mouse shows a laser beam and a larger hit dot, like a controller. SteamVR doesn't tell other programs where that page is (unlike Steam's pages, such as Library), so the mouse used to miss most of it: clicks went through to a desktop screen behind, and the dot disappeared. As a workaround, on that page only, the laser starts near your eye and SteamVR finds the page itself. See docs/design.md. - On SteamVR's Settings page, the 3D mouse shows a laser beam and a larger hit dot, like a controller. SteamVR doesn't tell other programs where that page is (unlike Steam's pages, such as Library), so the mouse used to miss most of it: clicks went through to a desktop screen behind, and the dot disappeared. As a workaround, on that page only, the laser starts near your eye and SteamVR finds the page itself. See docs/design.md.
- Remote desktop over VNC (Frametop Remote Access in the app menu, or `./desktops.sh remote on`) needs Tailscale on the Frame. It shows the primary screen only. The app turns it on and off, shows the address, and shows, copies, or changes the VNC password. The password is made at random on the Frame and kept in `~/.config/frametop-remote` (only you can read it); VNC limits it to 8 characters, and the tailnet encrypts the connection. Turning it on in a desktop that started with it off takes a desktop restart. - Remote desktop over VNC (Frametop Remote Access in the app menu, or `./desktops.sh remote on`) needs Tailscale on the Frame. It shows the primary screen only. The app turns it on and off, shows the address, and shows, copies, or changes the VNC password. The password is made at random on the Frame and kept in `~/.config/frametop-remote` (only you can read it); VNC limits it to 8 characters, and the tailnet encrypts the connection. Turning it on in a desktop that started with it off takes a desktop restart. It costs almost nothing until a viewer connects; the picture then takes a few seconds to appear.
- The desktop has no blur behind panels and menus, and no window animations, so it leaves the headset's GPU to SteamVR. Turn them back on in the Frametop desktop's System Settings (Desktop Effects, and Animation speed under General Behavior); Frametop won't turn them off again.
- Turning the displays off on a stand only turns their backlight off. SteamVR has no way for other programs to put the headset in standby, so tracking and rendering keep running, and the headset draws nearly its full power. - Turning the displays off on a stand only turns their backlight off. SteamVR has no way for other programs to put the headset in standby, so tracking and rendering keep running, and the headset draws nearly its full power.
## Reporting problems ## Reporting problems
+7 -1
View File
@@ -179,6 +179,12 @@ The private runtime directory also moves the session's document portal to `$XDG_
A podman container's monitor process (conmon) stays in the cgroup of whatever started the container, and `distrobox enter` starts it on demand. When a Frametop service happened to start the `dev` container, stopping that service stopped the container and everything in it, including the desktop's compositor. `scripts/container-up.sh` starts the container in a systemd scope of its own before anything enters it. It then waits for distrobox-init to log `container_setup_done`, as `distrobox enter` does only for containers it starts itself. A new container's first start takes a minute or more (it installs distrobox's dependencies and sets up passwordless sudo), and an install that entered right away met a sudo password prompt with no terminal to answer it ([#9](https://github.com/DeeJanuz/frametop/issues/9)). A podman container's monitor process (conmon) stays in the cgroup of whatever started the container, and `distrobox enter` starts it on demand. When a Frametop service happened to start the `dev` container, stopping that service stopped the container and everything in it, including the desktop's compositor. `scripts/container-up.sh` starts the container in a systemd scope of its own before anything enters it. It then waits for distrobox-init to log `container_setup_done`, as `distrobox enter` does only for containers it starts itself. A new container's first start takes a minute or more (it installs distrobox's dependencies and sets up passwordless sudo), and an install that entered right away met a sudo password prompt with no terminal to answer it ([#9](https://github.com/DeeJanuz/frametop/issues/9)).
KWin renders with OpenGL through zink on Turnip, Vulkan on the same GPU vrcompositor needs to hit its frame time, and on the Frame that costs CPU too. The nested session started with KWin's defaults: blur and background contrast on (no `[Plugins]` group in its kwinrc) and animations at full length. Blur re-renders what's behind every translucent panel and menu each time it changes, and every animated frame is one more frame for KWin and ft-screens to draw and send. They're off by default in the Frametop desktop. The session script writes them before KWin starts, only where the desktop's own config has no value, once: System Settings deletes a setting put back to KDE's default rather than writing it, so without the marker in `frametoprc` a user who turned blur back on would lose it at the next start. The effect ids (`blur`, `contrast`) are the ones built into KWin 6.2.5 on SteamOS; KWin reads `<id>Enabled` from `[Plugins]`.
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.
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.
Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`. Program names stay within 15 characters, because Linux truncates process names there and the scripts find programs with `pgrep -x` and `pkill -x`. That's why the prefix is `ft-`.
## Displays off on a stand ## Displays off on a stand
@@ -195,7 +201,7 @@ Staying awake while charging uses Steam's own setting rather than a logind sleep
Hiding the screens during a game kept them out of view, but Frametop kept using the headset. Measured on 2026-10-02 with gaze mode off and no game running, in shares of one core: our eye tracker (ft-eyes) about 60%, ft-eyegrab, ft-gaze and ft-gazed about 3 to 4% each; remote desktop (krdpserver, FreeRDP, Xvnc) about 2 cores while it ran; KWin about 13%, ft-screens about 4%. The gaze service ran at full rate whether gaze mode was on or not; now it idles while the gaze isn't used (gaze/README.md), and pausing stops it outright. Reading SteamVR's eye tracking 90 times a second also made it restart every 10 to 13 seconds during Beat Saber, and each restart took input focus from the game, which paused it (PR #13; since then ft-gaze skips SteamVR's gaze action during games, but our own tracker kept running). So pausing stops what costs the most and leaves windows where they are. Hiding the screens during a game kept them out of view, but Frametop kept using the headset. Measured on 2026-10-02 with gaze mode off and no game running, in shares of one core: our eye tracker (ft-eyes) about 60%, ft-eyegrab, ft-gaze and ft-gazed about 3 to 4% each; remote desktop (krdpserver, FreeRDP, Xvnc) about 2 cores while it ran; KWin about 13%, ft-screens about 4%. The gaze service ran at full rate whether gaze mode was on or not; now it idles while the gaze isn't used (gaze/README.md), and pausing stops it outright. Reading SteamVR's eye tracking 90 times a second also made it restart every 10 to 13 seconds during Beat Saber, and each restart took input focus from the game, which paused it (PR #13; since then ft-gaze skips SteamVR's gaze action during games, but our own tracker kept running). So pausing stops what costs the most and leaves windows where they are.
- A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not, so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together. - A hidden screen still cost as much as a visible one. ft-screens sent every committed screen its frame callback at 90 Hz whether its overlay showed or not (since then, a hidden screen always gets one a second; see the frame rates in reference.md), so KWin kept drawing, and its apps with it. Paused, ft-screens sends the callbacks once a second. A Wayland client draws again only after its last frame's callback, so KWin's output stalls, KWin's own clients stop getting theirs, and the whole desktop idles, without anything losing its connection. A second's pace, rather than none, keeps any client that waits on a callback from waiting forever. Stopping KWin or the apps with SIGSTOP would free the same, but a Wayland peer that stops reading overflows the other side's 4 KB socket buffer, which ends the connection: that's how the live desktop died once when its KWin stalled (`Data too big for buffer`). They also sit in different cgroups (KWin under steam.service when the VR launcher starts it, ft-screens in the dev container's), so no single freeze stops them together.
- The relay does the pausing because it's the one part that always runs, and the pointer helper keeps running because stopping it leaves its virtual controller connected with its last pose (the driver has no staleness timeout), maybe holding a hand role, with the 3D mouse dead. Releasing it does the job. The helper already checks for a scene app twice a second, so it's what tells the relay a game started. - The relay does the pausing because it's the one part that always runs, and the pointer helper keeps running because stopping it leaves its virtual controller connected with its last pose (the driver has no staleness timeout), maybe holding a hand role, with the 3D mouse dead. Releasing it does the job. The helper already checks for a scene app twice a second, so it's what tells the relay a game started.
- The gesture has to work during a game, but SteamVR input reaches only the app with input focus, and an overlay with global input (`steamvr/globalActionSetPriority`) takes the buttons it binds from the game. vrserver's web socket on 127.0.0.1:27062, which its controller binding page uses for the live view, reports every controller component whatever has focus, and reading it takes nothing. The game sees the clicks too, so the default is a gesture games hardly use: both thumbsticks, together, twice. "Together" means within 0.3 seconds of each other, so a stick held down to sprint while the other clicks doesn't count. The stream is about 160 messages a second, nearly all capacitive sensing, so the reader parses only the few that mention a gesture's button. A controller's root path changes while the 3D mouse holds its hand role (`/devices/cv/<serial>` instead of `/user/hand/right`), so the reader looks the controllers up again (an HTTP request to vrserver): when the relay's 3D mouse connects or lets go, when a message comes from a device it doesn't know, and every 30 seconds. It used to be every 3 seconds. - The gesture has to work during a game, but SteamVR input reaches only the app with input focus, and an overlay with global input (`steamvr/globalActionSetPriority`) takes the buttons it binds from the game. vrserver's web socket on 127.0.0.1:27062, which its controller binding page uses for the live view, reports every controller component whatever has focus, and reading it takes nothing. The game sees the clicks too, so the default is a gesture games hardly use: both thumbsticks, together, twice. "Together" means within 0.3 seconds of each other, so a stick held down to sprint while the other clicks doesn't count. The stream is about 160 messages a second, nearly all capacitive sensing, so the reader parses only the few that mention a gesture's button. A controller's root path changes while the 3D mouse holds its hand role (`/devices/cv/<serial>` instead of `/user/hand/right`), so the reader looks the controllers up again (an HTTP request to vrserver): when the relay's 3D mouse connects or lets go, when a message comes from a device it doesn't know, and every 30 seconds. It used to be every 3 seconds.
- Resuming starts remote desktop through `systemd-run --scope`: started straight from the relay, it would join the relay's cgroup and end with the next relay restart. - Resuming starts remote desktop through `systemd-run --scope`: started straight from the relay, it would join the relay's cgroup and end with the next relay restart.
+16 -1
View File
@@ -18,6 +18,16 @@ desktops.sh start | stop | restart | status | log [lines]
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. 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.
KWin's blur and background contrast effects and its animations are off in this desktop, because KWin draws on the headset's GPU, which SteamVR needs. The session script turns them off once, the first time it starts (it leaves a setting you already have alone, and marks it done in `~/.config/frametop/frametoprc`), so turning them back on sticks. In the Frametop desktop, System Settings → Window Management → Desktop Effects has Blur and Background Contrast, and General Behavior has Animation speed. Or from a terminal, then restart the desktop:
```
kwriteconfig6 --file ~/.config/frametop/kwinrc --group Plugins --key blurEnabled true
kwriteconfig6 --file ~/.config/frametop/kwinrc --group Plugins --key contrastEnabled true
kwriteconfig6 --file ~/.config/frametop/kdeglobals --group KDE --key AnimationDurationFactor 1
```
Two of the system's autostart programs don't start in this desktop: Discover's update notifier (`org.kde.discover.notifier`), which starts Discover to check for updates, and IBus (`ibus`), which can't reach the desktop's apps because KWin's input method is `input/ft-textinput`. The session script puts copies with `Hidden=true` in `~/.config/frametop/autostart` once (marked in `frametoprc`), and skips a name you already have a file for. Delete a copy to start that program again.
Settings are in two files, and Frametop Display Settings edits both. The screens (resolution, width in metres, scale, curve, which one has the taskbar) and their layout are in `~/.config/frametop-layout.json`. The backend, remote desktop, and pointer settings are in `~/.config/frametop.conf`; `session/frametop.conf.example` lists every key. Settings are in two files, and Frametop Display Settings edits both. The screens (resolution, width in metres, scale, curve, which one has the taskbar) and their layout are in `~/.config/frametop-layout.json`. The backend, remote desktop, and pointer settings are in `~/.config/frametop.conf`; `session/frametop.conf.example` lists every key.
Restarting the desktop closes its windows. Before the unit stops, `session/keep-apps.sh` moves every program started in the desktop into a systemd scope of its own, so background work such as servers, tmux, and builds keeps running. An app that shuts down its own helper processes when its window closes will still lose them; run that kind of work outside the desktop, for example as a systemd user service. Restarting the desktop closes its windows. Before the unit stops, `session/keep-apps.sh` moves every program started in the desktop into a systemd scope of its own, so background work such as servers, tmux, and builds keeps running. An app that shuts down its own helper processes when its window closes will still lose them; run that kind of work outside the desktop, for example as a systemd user service.
@@ -69,8 +79,11 @@ visibility always|dashboard|gesture|toggle wrist degrees gesture left|righ
hide | show | toggle controllers always|outside_games|dashboard ingames hide|visible pause on|off|state hide | show | toggle controllers always|outside_games|dashboard ingames hide|visible pause on|off|state
conceal N|all reveal N|all concealed cutouts on|off|state cutouts predict on|off cutouts lead ms conceal N|all reveal N|all concealed cutouts on|off|state cutouts predict on|off cutouts lead ms
float N mpp x y w h title unfloat N pose N matrix sub N k x y w h | sub N k off minimized N 0|1 carry N float N mpp x y w h title unfloat N pose N matrix sub N k x y w h | sub N k off minimized N 0|1 carry N
rates focused in_view hidden rates? watch seconds phase ms
``` ```
Each screen draws at a frame rate for how much of it you see. KWin draws a screen only after ft-screens gives it a frame callback, and its apps wait for theirs, so the rate of callbacks is the screen's frame rate, for KWin and the apps on it alike. A screen is focused while you look at it (within 12 degrees of where your head points), while a laser or the mouse is on it or was in the last 1.5 seconds, while it's carried, and while you type on it; it gets every display frame. The rest of what you can see (within 60 degrees) gets 15 frames a second, and a hidden screen, one behind you, and everything while paused get one a second. A level goes up at once and comes down after a moment (1.5 s from focused, 0.5 s from in view). A video, or anything moving over a large part of a screen (6% or more of it, redrawn on 8 commits in a row, 10 or more a second), keeps every frame while in view. A floating window is a screen of its own here. Nothing that stands still costs anything at any rate: KWin sends a frame only when something on the screen changed. `rates F V H` sets the three rates in Hz (0: every display frame; default `0 15 1`, also `ft-screens --rates 0,15,1`), and `rates?` shows them, the display's rate, and for each screen its level, its milliseconds between frames, and whether it counts as a video. `watch S` gives every screen full rate for S seconds: remote desktop renews it while a VNC client is connected, since a viewer sees what KWin draws. The ticks (SteamVR events and the callbacks) come once per display frame, 1 ms after the vsync (`phase ms` changes that, for tuning), in step with the display rather than on a timer that drifted through the frame.
`conceal` and `reveal` hide and show one screen on its own (`ft-layout hide` and `show` send them), and `concealed` lists those screens. `pause on` (from the input relay, when Frametop pauses for a VR game) hides every screen and floating window whatever else says, and slows the desktop down; `pause off` undoes it. `cutouts` turns the hand cutouts on and off (`ft-handsctl cutouts`). The last line is ft-floatd's, for floating windows: N is a floating window's panel, numbered on from the screens, one per spare output. `float` gives the window's rectangle in its output, metres per pixel, and the title bar's height, and shows the panel; `unfloat` hides it. `pose` places it (a 3x4 matrix, standing universe), `sub` shows popup or dialog k over it, `minimized` hides it while its window is minimized, and `carry` moves it with the laser that pressed the window's own title bar. `conceal` and `reveal` hide and show one screen on its own (`ft-layout hide` and `show` send them), and `concealed` lists those screens. `pause on` (from the input relay, when Frametop pauses for a VR game) hides every screen and floating window whatever else says, and slows the desktop down; `pause off` undoes it. `cutouts` turns the hand cutouts on and off (`ft-handsctl cutouts`). The last line is ft-floatd's, for floating windows: N is a floating window's panel, numbered on from the screens, one per spare output. `float` gives the window's rectangle in its output, metres per pixel, and the title bar's height, and shows the panel; `unfloat` hides it. `pose` places it (a 3x4 matrix, standing universe), `sub` shows popup or dialog k over it, `minimized` hides it while its window is minimized, and `carry` moves it with the laser that pressed the window's own title bar.
## Input relay ## Input relay
@@ -206,7 +219,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. - 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`). - 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 90 times. 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 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 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. 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.
@@ -259,6 +272,8 @@ It listens on port 5900 on the Frame's Tailscale address only, not the LAN, so i
No VNC server can capture KWin on SteamOS directly: `krfb` needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship, and `wayvnc` only works with wlroots compositors. So `session/remote-desktop.sh` captures the desktop with KDE's `krdpserver --plasma` on `127.0.0.1:3390`, and `session/vnc-bridge.sh` runs TigerVNC's `Xvnc` on display `:20` with a FreeRDP client inside it and serves that. Both run in the `dev` container, and the extra hop adds a little latency. krdp streams every screen; the VNC screen is the primary's size, and the FreeRDP window is shifted so the primary fills it (`ft-layout remote-view` gives the offset). krdp's own `--monitor` would stream just one screen, but it maps the pointer as if that screen sat at 0,0, so clicks would miss. When the layout changes, the VNC screen resizes and FreeRDP reconnects within a few seconds. No VNC server can capture KWin on SteamOS directly: `krfb` needs `xdg-desktop-portal-kde`, which SteamOS doesn't ship, and `wayvnc` only works with wlroots compositors. So `session/remote-desktop.sh` captures the desktop with KDE's `krdpserver --plasma` on `127.0.0.1:3390`, and `session/vnc-bridge.sh` runs TigerVNC's `Xvnc` on display `:20` with a FreeRDP client inside it and serves that. Both run in the `dev` container, and the extra hop adds a little latency. krdp streams every screen; the VNC screen is the primary's size, and the FreeRDP window is shifted so the primary fills it (`ft-layout remote-view` gives the offset). krdp's own `--monitor` would stream just one screen, but it maps the pointer as if that screen sat at 0,0, so clicks would miss. When the layout changes, the VNC screen resizes and FreeRDP reconnects within a few seconds.
FreeRDP runs only while a VNC viewer is connected, because while it's connected krdp captures and encodes every redraw. With no viewer, krdp has no RDP connection and so captures nothing, and Xvnc shows a black screen. When a viewer connects, the bridge starts FreeRDP, and the desktop appears about 3 seconds later; FreeRDP stops 45 seconds after the last viewer leaves (`VNC_IDLE_SEC` in the bridge's environment). The bridge looks for viewers with `ss` whenever Xvnc logs something, as it does for every connection, and every 5 seconds otherwise. While a viewer is connected, the bridge asks ft-screens to draw every screen at full rate (`watch 15` on `@ft_screens`, renewed every 5 seconds), so screens you aren't looking at in the headset, or a headset on a stand, don't stream at a low rate. It reads the primary screen's place again (`ft-layout remote-view`) only while FreeRDP runs, after `~/.config/frametop/kwinoutputconfig.json` or `~/.config/frametop-layout.json` changes, and once a minute.
With remote access on, the nested KWin runs with `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` and `KWIN_SCREENSHOT_NO_PERMISSION_CHECKS=1`, so any app in the Frametop desktop could capture its screens or inject input. The second one lets scripts take screenshots through KWin's `org.kde.KWin.ScreenShot2` D-Bus interface. This applies only to that desktop, not the stock one. Port 3389 is SteamOS's own `xrdp`, which starts a separate X11 session rather than showing the VR desktop. With remote access on, the nested KWin runs with `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1` and `KWIN_SCREENSHOT_NO_PERMISSION_CHECKS=1`, so any app in the Frametop desktop could capture its screens or inject input. The second one lets scripts take screenshots through KWin's `org.kde.KWin.ScreenShot2` D-Bus interface. This applies only to that desktop, not the stock one. Port 3389 is SteamOS's own `xrdp`, which starts a separate X11 session rather than showing the VR desktop.
## Limits ## Limits
+2 -2
View File
@@ -2,7 +2,7 @@
The Steam Frame's eye tracking as pointer input: a gaze mode for the 3D mouse (the pointer goes where you look, and the mouse does the last bit), and the tools to calibrate and measure it. The Steam Frame's eye tracking as pointer input: a gaze mode for the 3D mouse (the pointer goes where you look, and the mouse does the last bit), and the tools to calibrate and measure it.
- `ft-gaze` (C++, OpenVR, runs in the dev container) reads the eye tracker and prints one JSON line per sample (90 Hz). For each source, it gives the gaze direction relative to the head and the Frametop screen pixel it lands on. - `ft-gaze` (C++, OpenVR, runs in the dev container) reads the eye tracker and prints one JSON line per sample (90 Hz). For each source, it gives the gaze direction relative to the head and the Frametop screen pixel it lands on. The gaze service asks it for only the sources it uses (`--sources`, and `sources LIST` on its stdin): our tracker and mmap set 1 with Own tracker, about 700 bytes a line instead of 1.3 KB, and every source while a check or the calibration runs. So SteamVR's gaze action, the only source that calls into vrserver (twice a sample), is read only then. The probe gets them all.
- `gazecal.py` has what the probe and the gaze service share: the correction models, filters, and the reader for SteamVR's eye tracking log. - `gazecal.py` has what the probe and the gaze service share: the correction models, filters, and the reader for SteamVR's eye tracking log.
- `tracker/` is our own eye tracker, an alternative to SteamVR's: `ft-eyes` finds the pupils and glints in the eye-camera frames that `ft-eyegrab` (a small root service) copies out of SteamVR's tracker. See "Our own eye tracker" below. - `tracker/` is our own eye tracker, an alternative to SteamVR's: `ft-eyes` finds the pupils and glints in the eye-camera frames that `ft-eyegrab` (a small root service) copies out of SteamVR's tracker. See "Our own eye tracker" below.
- `probe/ft-gazeprobe` (GTK 4, host Python) is a fullscreen playground, for developing the gaze tracking: day to day, the calibration and the checks run in the headset panel (Quick check, Calibrate, and Check headset fit on the Gaze page). It runs ft-gaze, draws where you're looking, measures accuracy, and tries out hold-to-adjust clicking with a calibration that learns from your adjustments. - `probe/ft-gazeprobe` (GTK 4, host Python) is a fullscreen playground, for developing the gaze tracking: day to day, the calibration and the checks run in the headset panel (Quick check, Calibrate, and Check headset fit on the Gaze page). It runs ft-gaze, draws where you're looking, measures accuracy, and tries out hold-to-adjust clicking with a calibration that learns from your adjustments.
@@ -63,7 +63,7 @@ Ground rules, for anyone changing it:
- **Clean room.** Nothing of Valve's goes in: we don't decompile, disassemble, or patch the `eyetracking` binary or its network weights, and we don't copy their code or weights. Its public output (eye-server.mmap, read-only) is fair game as a baseline and as labels, and so are published papers and openly licensed pupil detectors (check each one's license: PuRe, PuReST, ElSe, and ExCuSe are non-commercial only). - **Clean room.** Nothing of Valve's goes in: we don't decompile, disassemble, or patch the `eyetracking` binary or its network weights, and we don't copy their code or weights. Its public output (eye-server.mmap, read-only) is fair game as a baseline and as labels, and so are published papers and openly licensed pupil detectors (check each one's license: PuRe, PuReST, ElSe, and ExCuSe are non-commercial only).
- **Root only reads.** ft-eyegrab never writes to, stops, or signals the `eyetracking` process, vrserver, or vrcompositor, never opens `/dev/adsp`, `/dev/cdsp`, or `/dev/spidev0.1`, and never writes to `/dev/shm/eye-server.mmap` (it also carries calibration clicks into SteamVR's tracker), `/opt`, or `/persist`. - **Root only reads.** ft-eyegrab never writes to, stops, or signals the `eyetracking` process, vrserver, or vrcompositor, never opens `/dev/adsp`, `/dev/cdsp`, or `/dev/spidev0.1`, and never writes to `/dev/shm/eye-server.mmap` (it also carries calibration clicks into SteamVR's tracker), `/opt`, or `/persist`.
- **Eye images are biometric data.** Recordings live outside the repo, in `~/.local/share/frametop/eyes/captures` (0700), and `.gitignore` catches stray frame dumps. They go nowhere but the machine that runs your offline jobs. - **Eye images are biometric data.** Recordings live outside the repo, in `~/.local/share/frametop/eyes/captures` (0700), and `.gitignore` catches stray frame dumps. They go nowhere but the machine that runs your offline jobs.
- **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. Replays, scoring, and training go to a PC. - **Mind the headset's budget.** Finding a pupil takes about 0.4 ms a frame while ft-eyes follows it, and 1.4-2.1 ms when it searches the whole frame. ft-eyes keeps OpenCV and numpy to one thread: their pools of one per core spun idle workers at about a quarter of a core, for frames this small. It also runs at nice 10 with SCHED_BATCH, and ft-gaze at nice 5 (not batch, since each sample goes on to the pointer): both run in the dev container's podman scope, out of reach of the gaze service's unit, on the cores vrcompositor and vrserver use at nice 0. Replays, scoring, and training go to a PC.
## Headset fit ## Headset fit
+77 -8
View File
@@ -4,7 +4,14 @@
// Every eye tracker sample (90 Hz) becomes one JSON line on stdout with each gaze source // Every eye tracker sample (90 Hz) becomes one JSON line on stdout with each gaze source
// hit-tested against the Frametop screens: // hit-tested against the Frametop screens:
// //
// Options: -v (log action errors), --watch-stdin (quit when stdin closes). // Options: -v (log action errors), --watch-stdin (quit when stdin closes; until then, a line
// "sources LIST" on stdin switches the sources as --sources does), --sources LIST (comma-
// separated: action, mmap1, mmap2, left, right, own, and eye for EYE; or all, the default).
// Sources left out are read not at all and print as {"ok":0} ("eye" as null), so every line
// keeps the same keys. The gaze service asks for the ones it uses (own and mmap1 with our
// tracker), and all of them while a check or the calibration runs. Only the action costs
// SteamVR anything (two calls into vrserver per sample), and a line with every source is
// about 1.5 KB, 130 KB a second through podman's relay.
// //
// {"t":<sample time, CLOCK_MONOTONIC_RAW s>,"age":<ms old when read>,"n":<sample counter>, // {"t":<sample time, CLOCK_MONOTONIC_RAW s>,"age":<ms old when read>,"n":<sample counter>,
// "head":{"yaw":..,"pitch":..,"hit":HIT}, head forward ray (for head nudging) // "head":{"yaw":..,"pitch":..,"hit":HIT}, head forward ray (for head nudging)
@@ -58,6 +65,7 @@
#include <algorithm> #include <algorithm>
#include <atomic> #include <atomic>
#include <cerrno>
#include <chrono> #include <chrono>
#include <climits> #include <climits>
#include <cmath> #include <cmath>
@@ -72,6 +80,7 @@
#include <fcntl.h> #include <fcntl.h>
#include <sys/mman.h> #include <sys/mman.h>
#include <sys/resource.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/un.h> #include <sys/un.h>
@@ -389,6 +398,30 @@ std::string SrcJson(const std::vector<Screen> &screens, const vr::HmdMatrix34_t
return buf + extra + "\"hit\":" + HitJson(screens, head, yaw, pitch) + "}"; return buf + extra + "\"hit\":" + HitJson(screens, head, yaw, pitch) + "}";
} }
// Sources (--sources, "sources LIST" on stdin): a bit each.
enum : unsigned { kAction = 1, kMmap1 = 2, kMmap2 = 4, kLeft = 8, kRight = 16, kOwn = 32, kEye = 64, kAll = 127 };
bool ParseSources(const std::string &list, unsigned &mask) {
static const struct {
const char *name;
unsigned bit;
} names[] = {{"action", kAction}, {"mmap1", kMmap1}, {"mmap2", kMmap2}, {"left", kLeft},
{"right", kRight}, {"own", kOwn}, {"eye", kEye}, {"all", kAll}};
unsigned m = 0;
size_t at = 0;
while (at <= list.size()) {
const size_t comma = std::min(list.find(',', at), list.size());
const std::string name = list.substr(at, comma - at);
bool known = false;
for (const auto &n : names)
if (name == n.name) m |= n.bit, known = true;
if (!known) return false;
at = comma + 1;
}
mask = m;
return true;
}
// Head poses of the last half second, so a sample can use the pose at its own time. // Head poses of the last half second, so a sample can use the pose at its own time.
class PoseHistory { class PoseHistory {
public: public:
@@ -426,17 +459,42 @@ std::string ExeDir() {
int main(int argc, char **argv) { int main(int argc, char **argv) {
bool verbose = false, watchStdin = false; bool verbose = false, watchStdin = false;
std::atomic<unsigned> sources{kAll};
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "-v") == 0) verbose = true; if (std::strcmp(argv[i], "-v") == 0) verbose = true;
if (std::strcmp(argv[i], "--watch-stdin") == 0) watchStdin = true; if (std::strcmp(argv[i], "--watch-stdin") == 0) watchStdin = true;
if (std::strcmp(argv[i], "--sources") == 0 && i + 1 < argc) {
unsigned m;
if (ParseSources(argv[++i], m))
sources = m;
else
std::fprintf(stderr, "ft-gaze: --sources %s: unknown source (all used)\n", argv[i]);
} }
}
// Nice 5, before any thread starts (they inherit it): we run in the dev container's podman
// scope, beside vrcompositor and vrserver at nice 0, and the gaze service's unit doesn't
// reach us. Not SCHED_BATCH, as ft-eyes is: that would let each wakeup wait out another
// task's turn, and each sample goes on to the pointer.
errno = 0;
const int nice0 = getpriority(PRIO_PROCESS, 0);
if (errno == 0 && nice0 < 5 && setpriority(PRIO_PROCESS, 0, 5) != 0)
std::fprintf(stderr, "ft-gaze: nice: %s\n", std::strerror(errno));
// --watch-stdin: quit when stdin closes. The probe runs us through distrobox, which // --watch-stdin: quit when stdin closes. The probe runs us through distrobox, which
// passes neither its signals nor a closed stdout on to us, but does pass stdin's end. // passes neither its signals nor a closed stdout on to us, but does pass stdin's end.
// Lines on stdin until then: "sources LIST".
std::atomic<bool> stdinClosed{false}; std::atomic<bool> stdinClosed{false};
if (watchStdin) if (watchStdin)
std::thread([&stdinClosed] { std::thread([&stdinClosed, &sources] {
char c[256]; char c[256];
while (read(0, c, sizeof c) > 0) { std::string line;
ssize_t n;
while ((n = read(0, c, sizeof c)) > 0) {
line.append(c, size_t(n));
for (size_t nl; (nl = line.find('\n')) != std::string::npos; line.erase(0, nl + 1)) {
unsigned m;
if (line.compare(0, 8, "sources ") == 0 && ParseSources(line.substr(8, nl - 8), m)) sources = m;
}
if (line.size() > 4096) line.clear(); // no newline in sight: not ours
} }
stdinClosed = true; stdinClosed = true;
}).detach(); }).detach();
@@ -509,6 +567,7 @@ int main(int argc, char **argv) {
if (fresh && hp.bPoseIsValid) { if (fresh && hp.bPoseIsValid) {
lastEmit = now; lastEmit = now;
const unsigned want = sources;
const auto list = screens.Get(); const auto list = screens.Get();
const vr::HmdMatrix34_t &headNow = hp.mDeviceToAbsoluteTracking; const vr::HmdMatrix34_t &headNow = hp.mDeviceToAbsoluteTracking;
vr::HmdMatrix34_t headThen = headNow; vr::HmdMatrix34_t headThen = headNow;
@@ -517,7 +576,7 @@ int main(int argc, char **argv) {
// SteamVR's action: a room-space origin and fixation point, turned into the head // SteamVR's action: a room-space origin and fixation point, turned into the head
// frame so every source reports the same kind of angles. // frame so every source reports the same kind of angles.
std::string action = "{\"ok\":0}"; std::string action = "{\"ok\":0}";
if (!inGame) { if (!inGame && (want & kAction)) {
vr::VRActiveActionSet_t active{}; vr::VRActiveActionSet_t active{};
active.ulActionSet = set; active.ulActionSet = set;
active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin; active.nPriority = vr::k_nActionSetOverlayGlobalPriorityMin;
@@ -560,26 +619,33 @@ int main(int argc, char **argv) {
return std::string(b); return std::string(b);
}; };
char extra[256]; char extra[256];
if (want & kMmap1) {
std::snprintf(extra, sizeof extra, "\"dist\":%.3f,\"open\":[%.3f,%.3f],\"lr\":%.3f,", Length(s.fix1), std::snprintf(extra, sizeof extra, "\"dist\":%.3f,\"open\":[%.3f,%.3f],\"lr\":%.3f,", Length(s.fix1),
s.open[0], s.open[1], lr(s.left1, s.right1)); s.open[0], s.open[1], lr(s.left1, s.right1));
m1 = SrcJson(list, headThen, s.left1 + s.right1, extra + eyes(s.left1, s.right1) + unc(s.var1)); m1 = SrcJson(list, headThen, s.left1 + s.right1, extra + eyes(s.left1, s.right1) + unc(s.var1));
}
if (want & kMmap2) {
std::snprintf(extra, sizeof extra, "\"lr\":%.3f,", lr(s.left2, s.right2)); std::snprintf(extra, sizeof extra, "\"lr\":%.3f,", lr(s.left2, s.right2));
m2 = SrcJson(list, headThen, s.left2 + s.right2, extra + eyes(s.left2, s.right2) + unc(s.var2)); m2 = SrcJson(list, headThen, s.left2 + s.right2, extra + eyes(s.left2, s.right2) + unc(s.var2));
left = SrcJson(list, headThen, s.left2); }
right = SrcJson(list, headThen, s.right2); if (want & kLeft) left = SrcJson(list, headThen, s.left2);
if (want & kRight) right = SrcJson(list, headThen, s.right2);
// "new" compares with the last sample, so the last measurement is kept either way.
const float *m = s.meas; const float *m = s.meas;
const bool newL = m[0] != lastMeas[0] || m[1] != lastMeas[1]; const bool newL = m[0] != lastMeas[0] || m[1] != lastMeas[1];
const bool newR = m[2] != lastMeas[2] || m[3] != lastMeas[3]; const bool newR = m[2] != lastMeas[2] || m[3] != lastMeas[3];
std::memcpy(lastMeas, m, sizeof lastMeas); std::memcpy(lastMeas, m, sizeof lastMeas);
if (want & kEye) {
std::snprintf(extra, sizeof extra, "{\"q\":[%.3g,%.3g],\"m\":[[%.4f,%.4f],[%.4f,%.4f]],\"new\":[%d,%d]}", std::snprintf(extra, sizeof extra, "{\"q\":[%.3g,%.3g],\"m\":[[%.4f,%.4f],[%.4f,%.4f]],\"new\":[%d,%d]}",
(m[4] + m[5]) / 2, (m[6] + m[7]) / 2, m[0], m[1], m[2], m[3], int(newL), int(newR)); (m[4] + m[5]) / 2, (m[6] + m[7]) / 2, m[0], m[1], m[2], m[3], int(newL), int(newR));
eye = extra; eye = extra;
} }
}
// Our tracker: its own sample time picks the head pose, like the mmap's. // Our tracker: its own sample time picks the head pose, like the mmap's.
std::string own = "{\"ok\":0}"; std::string own = "{\"ok\":0}";
OwnSample o; OwnSample o;
if (ownFile.Read(o) && now - o.t < 0.1) { if ((want & kOwn) && ownFile.Read(o) && now - o.t < 0.1) {
vr::HmdMatrix34_t headOwn = headNow; vr::HmdMatrix34_t headOwn = headNow;
history.At(o.t, headOwn); history.At(o.t, headOwn);
auto pair = [](bool ok, float a, float b) { auto pair = [](bool ok, float a, float b) {
@@ -624,7 +690,10 @@ int main(int argc, char **argv) {
break; break;
} }
if (stdinClosed) break; if (stdinClosed) break;
std::this_thread::sleep_for(std::chrono::milliseconds(2)); // 250 passes a second: a new sample is printed within 4 ms (2 on average) of appearing,
// and the pose history has a pose within 2 ms of any sample's time (a 0.2 degree head
// turn at 100 degrees a second). Every 2 ms read the pose and the events twice as often.
std::this_thread::sleep_for(std::chrono::milliseconds(4));
} }
screens.Stop(); screens.Stop();
vr::VR_Shutdown(); vr::VR_Shutdown();
+38 -3
View File
@@ -76,6 +76,12 @@ off. A check asked for while idle waits for the tracker to start (at most WAKE_S
tracker's next click after it starts again re-seats it, as after the headset was off, so turning tracker's next click after it starts again re-seats it, as after the headset was off, so turning
gaze mode on after a while opens the quick check. gaze mode on after a while opens the quick check.
ft-gaze prints only the sources this uses ("sources LIST" on its stdin, see wanted_sources):
own and mmap1 with our tracker, left, right and mmap1 with SteamVR's eyes, and every source
while a check or the calibration runs or is asked for, since those record them all. So
SteamVR's gaze action, the one source that calls into vrserver, is read only then (or with
--source action).
Checks and calibration (gaze/gazecheck.py): a one-dot quick check when the headset goes on or Checks and calibration (gaze/gazecheck.py): a one-dot quick check when the headset goes on or
our tracker asks for a click, and the full calibration when gaze mode comes on without one, our tracker asks for a click, and the full calibration when gaze mode comes on without one,
both in a panel fixed to the headset (gaze/panel/ft-gazepanel, which this service runs too). both in a panel fixed to the headset (gaze/panel/ft-gazepanel, which this service runs too).
@@ -260,6 +266,7 @@ class Service:
self.sel.register(self.eyes_sock, selectors.EVENT_READ, "own") self.sel.register(self.eyes_sock, selectors.EVENT_READ, "own")
self.checks = Checks(self, self.sel) self.checks = Checks(self, self.sel)
self.proc = None self.proc = None
self.proc_sources = None # what ft-gaze was last told to print
self.buf = b"" self.buf = b""
self.restart_at = 0.0 self.restart_at = 0.0
self.running = True self.running = True
@@ -459,6 +466,31 @@ class Service:
# --- ft-gaze --- # --- ft-gaze ---
def wanted_sources(self):
"""The sources ft-gaze should print (its --sources): what on_sample and the checks read.
mmap1 always (blinks, lost eyes, and the headset going on, from its openness and
variances); everything while a check runs or waits, since they record every source."""
c = self.checks
if c.active or c.pending:
return "all"
kind = self.kind
if kind == "own":
return "own,mmap1"
if kind == "eyes":
return "left,right,mmap1"
return ",".join(dict.fromkeys((self.source, "mmap1", "mmap2"))) # mmap2: each eye, for the fallback
def sync_sources(self):
want = self.wanted_sources()
if not self.proc or want == self.proc_sources:
return
try:
self.proc.stdin.write(f"sources {want}\n".encode())
self.proc.stdin.flush()
except (OSError, ValueError):
return # it's stopping: read_stdout notices
self.proc_sources = want
def start_helper(self): def start_helper(self):
if not HELPER.exists(): if not HELPER.exists():
log(f"ft-gaze isn't built: run {REPO}/gaze/build.sh") log(f"ft-gaze isn't built: run {REPO}/gaze/build.sh")
@@ -469,9 +501,11 @@ class Service:
subprocess.run([str(REPO / "scripts" / "container-up.sh")], env=env, check=False) subprocess.run([str(REPO / "scripts" / "container-up.sh")], env=env, check=False)
distrobox = Path.home() / ".local" / "bin" / "distrobox" distrobox = Path.home() / ".local" / "bin" / "distrobox"
# ft-gaze quits when its stdin closes: the one thing distrobox passes on. # ft-gaze quits when its stdin closes: the one thing distrobox passes on.
self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin"], env=env, sources = self.wanted_sources()
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, self.proc = subprocess.Popen([str(distrobox), "enter", "dev", "--", str(HELPER), "--watch-stdin",
start_new_session=True) "--sources", sources], env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, start_new_session=True)
self.proc_sources = sources
os.set_blocking(self.proc.stdout.fileno(), False) os.set_blocking(self.proc.stdout.fileno(), False)
os.set_blocking(self.proc.stderr.fileno(), False) os.set_blocking(self.proc.stderr.fileno(), False)
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout") self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
@@ -876,6 +910,7 @@ class Service:
elif key.data == "stderr" and self.proc: elif key.data == "stderr" and self.proc:
self.read_stderr() self.read_stderr()
self.checks.tick() self.checks.tick()
self.sync_sources()
if now >= next_periodic: if now >= next_periodic:
self.periodic() self.periodic()
next_periodic = now + 1.0 next_periodic = now + 1.0
+6 -1
View File
@@ -47,6 +47,7 @@
#include <drm_fourcc.h> #include <drm_fourcc.h>
#include <fcntl.h> #include <fcntl.h>
#include <gbm.h> #include <gbm.h>
#include <poll.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/un.h> #include <sys/un.h>
#include <unistd.h> #include <unistd.h>
@@ -524,7 +525,11 @@ int main(int argc, char **argv) {
if (!shown) ov->ShowOverlay(h), shown = true; if (!shown) ov->ShowOverlay(h), shown = true;
dirty = false; dirty = false;
} }
std::this_thread::sleep_for(std::chrono::milliseconds(visible ? 10 : 50)); // Until a command comes, or 10 ms while shown (SteamVR's events). Hidden, it waits up to a
// second: it woke 20 to 30 times a second for nothing, the main cost left with gaze idle.
// A closed stdin (--watch-stdin) wakes it too, so quitting doesn't wait.
pollfd fds[2] = {{sock, POLLIN, 0}, {0, POLLIN, 0}};
poll(fds, watchStdin ? 2 : 1, visible ? 10 : 1000);
} }
ov->DestroyOverlay(h); ov->DestroyOverlay(h);
buffers.Drop(); buffers.Drop();
+25 -5
View File
@@ -37,14 +37,26 @@ gazed.read_settings = lambda: ("steam", "steam", "auto", 55.0)
logs = [] logs = []
gazed.log = gazecheck.log = lambda msg: logs.append(msg) gazed.log = gazecheck.log = lambda msg: logs.append(msg)
# The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes. # The fake ft-gaze: 90 samples a second, both eyes seen, until its stdin closes. It logs the
# sources it was asked for: "argv LIST" (--sources), then "line LIST" for each "sources LIST".
tmp = tempfile.mkdtemp(prefix="ft-gaze-idle-test-") tmp = tempfile.mkdtemp(prefix="ft-gaze-idle-test-")
FAKE = os.path.join(tmp, "ft-gaze") FAKE = os.path.join(tmp, "ft-gaze")
SOURCES_LOG = os.path.join(tmp, "sources.log")
with open(FAKE, "w") as f: with open(FAKE, "w") as f:
f.write('''import json, select, sys, time f.write('''import json, os, select, sys, time
log = open(sys.argv[1], "a", buffering=1)
log.write("argv " + (sys.argv[sys.argv.index("--sources") + 1] if "--sources" in sys.argv else "-") + "\\n")
buf = b""
while True: while True:
if select.select([sys.stdin], [], [], 1 / 90)[0] and not sys.stdin.read(1): if select.select([sys.stdin], [], [], 1 / 90)[0]:
data = os.read(0, 4096)
if not data:
break break
buf += data
while b"\\n" in buf:
line, buf = buf.split(b"\\n", 1)
if line.startswith(b"sources "):
log.write("line " + line[8:].decode() + "\\n")
eye = {"hy": 1.0, "hp": 2.0} eye = {"hy": 1.0, "hp": 2.0}
print(json.dumps({"t": time.monotonic(), "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.001, 0.001], print(json.dumps({"t": time.monotonic(), "src": {"mmap1": {"hy": 1.0, "hp": 2.0, "unc": [0.001, 0.001],
"open": [0.8, 0.8]}, "left": eye, "right": eye}}), flush=True) "open": [0.8, 0.8]}, "left": eye, "right": eye}}), flush=True)
@@ -55,8 +67,9 @@ started = []
def start_helper(self): def start_helper(self):
"""ft-gaze, straight from here instead of the dev container.""" """ft-gaze, straight from here instead of the dev container."""
import selectors import selectors
self.proc = subprocess.Popen([sys.executable, FAKE], stdin=subprocess.PIPE, stdout=subprocess.PIPE, self.proc = subprocess.Popen([sys.executable, FAKE, SOURCES_LOG, "--sources", self.wanted_sources()],
stderr=subprocess.PIPE) stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.proc_sources = self.wanted_sources()
os.set_blocking(self.proc.stdout.fileno(), False) os.set_blocking(self.proc.stdout.fileno(), False)
os.set_blocking(self.proc.stderr.fileno(), False) os.set_blocking(self.proc.stderr.fileno(), False)
self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout") self.sel.register(self.proc.stdout, selectors.EVENT_READ, "stdout")
@@ -142,17 +155,24 @@ check("lease over (2 s + IDLE_AFTER): idle", wait(lambda: not svc.awake, 4.5), T
time.sleep(0.5) time.sleep(0.5)
logs.clear() logs.clear()
open(SOURCES_LOG, "w").close()
count = len(started) count = len(started)
check("quick check while idle: queued, waking", ask("quickcal"), "ok waking the eye tracker first") check("quick check while idle: queued, waking", ask("quickcal"), "ok waking the eye tracker first")
check("it woke", wait(lambda: svc.awake and len(started) > count, 1.5), True) check("it woke", wait(lambda: svc.awake and len(started) > count, 1.5), True)
check("once the tracker sends, it ran (and says why it couldn't open)", check("once the tracker sends, it ran (and says why it couldn't open)",
wait(lambda: any("quickcal, asked for while idle: the panel isn't running" in m for m in logs), 3), True) wait(lambda: any("quickcal, asked for while idle: the panel isn't running" in m for m in logs), 3), True)
check("nothing left pending", svc.checks.pending, None) check("nothing left pending", svc.checks.pending, None)
sources = open(SOURCES_LOG).read().split("\n")
check("ft-gaze started with every source for the check", sources[0], "argv all")
in_use = svc.wanted_sources()
check("then only those in use (SteamVR's tracker: not the action, not own)",
(f"line {in_use}" in sources, in_use != "all", "action" in in_use, "own" in in_use), (True, True, False, False))
check("idle again after", wait(lambda: not svc.awake, 3), True) check("idle again after", wait(lambda: not svc.awake, 3), True)
print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True) print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True)
svc.running = False svc.running = False
time.sleep(0.7) time.sleep(0.7)
os.remove(FAKE) os.remove(FAKE)
os.remove(SOURCES_LOG)
os.rmdir(tmp) os.rmdir(tmp)
os._exit(1 if failures else 0) os._exit(1 if failures else 0)
+5
View File
@@ -9,6 +9,11 @@ the search runs again with a smaller closing.
import cv2 import cv2
import numpy as np import numpy as np
# One thread: OpenCV's pool of one per core costs more than it saves on a frame this small
# (a 140-240 px window while it follows the pupil). Its idle workers spun and yielded about
# 14,000 times a second each, a quarter of a core, beside SteamVR's compositor.
cv2.setNumThreads(1)
DARK = 30 # pupil pixels are below this (the face around it is 40-180) DARK = 30 # pupil pixels are below this (the face around it is 40-180)
MIN_AREA = 150 # pupil area range in pixels MIN_AREA = 150 # pupil area range in pixels
MAX_AREA = 20000 MAX_AREA = 20000
+7
View File
@@ -63,6 +63,13 @@ reading only.
frame when its camera starts the frame after next (no slot is rewritten sooner than three frame when its camera starts the frame after next (no slot is rewritten sooner than three
frames), ignores late writes to the frame just finished, and saves from a separate frames), ignores late writes to the frame just finished, and saves from a separate
thread. fit1 may hold a few percent of torn frames. thread. fit1 may hold a few percent of torn frames.
- `--share` (2026-10-03) checks only the slot each camera writes next, from the two before
(the orders above, learned again if they change; all four slots for 2 s after a frame turns
up elsewhere), sleeps until 2.5 ms before the next frame is due, then looks every 1 ms with
0.5 ms of timer slack. Against the 0.3 ms poll of all eight slots, on simulated cameras:
207 wakeups a second instead of 1,486, 0.8% of a core instead of 3.6% (more on the real
DMA-BUF memory), no torn or skipped frames, and a frame's start seen 1.35 ms late on
average instead of 0.76. `--rec` still polls all slots every 0.3 ms, for its times.
- Eye tracking stops when the headset is off ("HMD off, stopping eye tracking"), so - Eye tracking stops when the headset is off ("HMD off, stopping eye tracking"), so
recordings are empty then. recordings are empty then.
- **Which camera is which eye** (capture fit1, 2026-09-29, closing one eye at a time): - **Which camera is which eye** (capture fit1, 2026-09-29, closing one eye at a time):
+66 -9
View File
@@ -46,6 +46,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/mman.h> #include <sys/mman.h>
#include <sys/prctl.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/syscall.h> #include <sys/syscall.h>
#include <time.h> #include <time.h>
@@ -288,20 +289,53 @@ static int eye_buffer(void) {
// Calls done(frame, slot, time) for every complete eye-camera frame until `seconds` pass // Calls done(frame, slot, time) for every complete eye-camera frame until `seconds` pass
// (forever if negative), a stop signal comes, the tracker process goes away, or keep() // (forever if negative), a stop signal comes, the tracker process goes away, or keep()
// (checked about every 0.25 s, when given) says to stop. // (checked about every 0.25 s, when given) says to stop. Looks every `poll_us`.
// //
// A frame lands over several milliseconds, in bursts, and its last bursts can come after // A frame lands over several milliseconds, in bursts, and its last bursts can come after
// the camera has started its next frame. A slot isn't rewritten until at least three frames // the camera has started its next frame. A slot isn't rewritten until at least three frames
// later (camera 0 cycles 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), so a frame is passed on when // later (camera 0 cycles 3,0,1,2; camera 1 7,5,4,6,5,7,6,4), so a frame is passed on when
// its camera starts the frame after next. Changes to the slot just finished are late bursts, // its camera starts the frame after next. Changes to the slot just finished are late bursts,
// not a new frame. A frame's time is when its slot first changed. // not a new frame. A frame's time is when its slot first changed.
//
// Each look checks only the slot each camera writes next: the one that followed the last two
// before (after[][], seeded with the orders above and learned as frames come). Every check
// reads 256 words spread over a frame in DMA-BUF memory, so all eight slots each time was
// most of the cost. A camera with nothing in its expected slot for 1.5 frames, or with no
// order known yet, has all its slots checked until a frame comes. When that finds a frame
// somewhere else (the order changed, or a frame was missed), all its slots are checked for
// SCAN_AFTER_MISS, as before, while after[][] learns the new order. A slot's fingerprint is
// taken again when it stops being one of the two in use, so a check later sees only a new frame.
// Between frames it sleeps until FRAME_EARLY before the next one is due (the cameras run at
// 90 fps, within a ms of each other), then looks every `poll_us`.
#define FRAME_DUE (1.5 / 90) // s: a camera that hasn't started a frame by then gets all its slots checked
#define SCAN_AFTER_MISS 2.0 // s of checking all slots after a frame came in an unexpected one
#define CAMS_IDLE 0.5 // s without a frame from either camera: look 4 times less often
#define FRAME_EARLY 0.0025 // s before a frame is due to start looking for it
// When to start looking for the next frame: FRAME_EARLY before the first camera's is due. A
// camera already late (it lost its order, or stopped) means now.
static double next_due(const double last[2], double t) {
double due = 1e300;
for (int cam = 0; cam < 2; cam++) {
double d = last[cam] + 1.0 / 90 - FRAME_EARLY;
if (t - last[cam] > CAMS_IDLE) continue; // stopped: the other one sets the pace
if (d < due) due = d;
}
return due < 1e300 ? due : t;
}
static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8_t *, int, double), static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8_t *, int, double),
int (*keep)(void)) { int (*keep)(void), unsigned poll_us) {
static const int order[2][8] = {{3, 0, 1, 2, 3, 0, 1, 2}, {7, 5, 4, 6, 5, 7, 6, 4}};
int after[EYE_SLOTS][EYE_SLOTS];
memset(after, -1, sizeof after);
for (int c = 0; c < 2; c++)
for (int i = 0; i < 8; i++) after[order[c][i]][order[c][(i + 1) % 8]] = order[c][(i + 2) % 8];
uint64_t sig[EYE_SLOTS]; uint64_t sig[EYE_SLOTS];
double first[EYE_SLOTS]; double first[EYE_SLOTS];
int cur[2] = {-1, -1}, prev[2] = {-1, -1}; int cur[2] = {-1, -1}, prev[2] = {-1, -1};
for (int k = 0; k < EYE_SLOTS; k++) sig[k] = frame_sig(bufs[b].p + slot_start(k)), first[k] = 0; for (int k = 0; k < EYE_SLOTS; k++) sig[k] = frame_sig(bufs[b].p + slot_start(k)), first[k] = 0;
double start = now(), checked = start, kept = start; double start = now(), checked = start, kept = start, last[2] = {start, start}, scan_until[2] = {0, 0};
char proc[64]; char proc[64];
snprintf(proc, sizeof proc, "/proc/%d", pid); snprintf(proc, sizeof proc, "/proc/%d", pid);
while ((seconds < 0 || now() - start < seconds) && !stop_rec) { while ((seconds < 0 || now() - start < seconds) && !stop_rec) {
@@ -315,18 +349,36 @@ static void poll_frames(int b, double seconds, int pid, void (*done)(const uint8
if (!keep()) return; if (!keep()) return;
kept = t; kept = t;
} }
for (int k = 0; k < EYE_SLOTS; k++) { for (int cam = 0; cam < 2; cam++) {
int next = prev[cam] >= 0 ? after[prev[cam]][cur[cam]] : -1;
int all = next < 0 || t - last[cam] > FRAME_DUE || t < scan_until[cam];
int from = all ? cam * 4 : next, to = all ? cam * 4 + 4 : next + 1;
for (int k = from; k < to; k++) {
uint64_t s = frame_sig(bufs[b].p + slot_start(k)); uint64_t s = frame_sig(bufs[b].p + slot_start(k));
if (s == sig[k]) continue; if (s == sig[k]) continue;
sig[k] = s; sig[k] = s;
int cam = k >= 4;
if (k == cur[cam] || k == prev[cam]) continue; // landing, or a late burst if (k == cur[cam] || k == prev[cam]) continue; // landing, or a late burst
if (prev[cam] >= 0) done(bufs[b].p + slot_start(prev[cam]), prev[cam], first[prev[cam]]); if (next >= 0 && k != next) scan_until[cam] = t + SCAN_AFTER_MISS;
if (prev[cam] >= 0) {
done(bufs[b].p + slot_start(prev[cam]), prev[cam], first[prev[cam]]);
after[prev[cam]][cur[cam]] = k;
// Out of use from now on: later changes are a new frame.
sig[prev[cam]] = frame_sig(bufs[b].p + slot_start(prev[cam]));
}
prev[cam] = cur[cam]; prev[cam] = cur[cam];
cur[cam] = k; cur[cam] = k;
first[k] = t; first[k] = t;
last[cam] = t;
next = prev[cam] >= 0 ? after[prev[cam]][cur[cam]] : -1;
} }
usleep(300); }
double wait = poll_us * 1e-6;
if (t - last[0] > CAMS_IDLE && t - last[1] > CAMS_IDLE) {
wait *= 4;
} else if (next_due(last, t) - t > wait) {
wait = next_due(last, t) - t;
}
usleep((useconds_t)(wait * 1e6));
} }
} }
@@ -356,7 +408,7 @@ static int rec(double seconds, const char *dir, int pid) {
pthread_t writer; pthread_t writer;
pthread_create(&writer, NULL, ring_writer, NULL); pthread_create(&writer, NULL, ring_writer, NULL);
double start = now(); double start = now();
poll_frames(b, seconds, pid, rec_frame, NULL); poll_frames(b, seconds, pid, rec_frame, NULL, 300); // 0.3 ms: recordings' times
pthread_mutex_lock(&ring.mu); pthread_mutex_lock(&ring.mu);
ring.done = 1; ring.done = 1;
pthread_cond_signal(&ring.cv); pthread_cond_signal(&ring.cv);
@@ -383,6 +435,10 @@ static int rec(double seconds, const char *dir, int pid) {
#define SHARE_SLOTS 8 #define SHARE_SLOTS 8
#define SHARE_MAGIC 0x31434546u // "FEC1" #define SHARE_MAGIC 0x31434546u // "FEC1"
#define WANT_FRESH 3.0 // seconds a touch of the --want file lasts #define WANT_FRESH 3.0 // seconds a touch of the --want file lasts
// How often --share looks for frames, with a timer slack that lets the kernel group the
// wakeups: a frame reaches ft-eyes 1 to 1.5 ms after its camera starts the next, not 0.3.
#define SHARE_POLL_US 1000
#define SHARE_SLACK_NS 500000
typedef struct { typedef struct {
uint32_t magic, version, width, height, slots, entry_size; uint32_t magic, version, width, height, slots, entry_size;
@@ -462,6 +518,7 @@ static int share_loop(const char *path) {
.slots = SHARE_SLOTS, .entry_size = (uint32_t)esize}; .slots = SHARE_SLOTS, .entry_size = (uint32_t)esize};
signal(SIGINT, on_stop); signal(SIGINT, on_stop);
signal(SIGTERM, on_stop); signal(SIGTERM, on_stop);
if (prctl(PR_SET_TIMERSLACK, SHARE_SLACK_NS, 0, 0, 0) != 0) perror("PR_SET_TIMERSLACK");
fprintf(stderr, "ft-eyegrab: sharing frames in %s%s%s\n", path, want_path ? " while wanted by " : "", fprintf(stderr, "ft-eyegrab: sharing frames in %s%s%s\n", path, want_path ? " while wanted by " : "",
want_path ? want_path : ""); want_path ? want_path : "");
int pid = -1, idle = -1, missing = 0; int pid = -1, idle = -1, missing = 0;
@@ -486,7 +543,7 @@ static int share_loop(const char *path) {
if (idle != 0 || missing) fprintf(stderr, "ft-eyegrab: copying frames from eyetracking %d\n", pid); if (idle != 0 || missing) fprintf(stderr, "ft-eyegrab: copying frames from eyetracking %d\n", pid);
idle = 0, missing = 0; idle = 0, missing = 0;
h->tracker_pid = pid; h->tracker_pid = pid;
poll_frames(eye_buffer(), -1, pid, share_frame, wanted); poll_frames(eye_buffer(), -1, pid, share_frame, wanted, SHARE_POLL_US);
struct stat st; struct stat st;
char proc[64]; char proc[64];
snprintf(proc, sizeof proc, "/proc/%d", pid); snprintf(proc, sizeof proc, "/proc/%d", pid);
+42 -4
View File
@@ -50,6 +50,7 @@ import json
import math import math
import mmap import mmap
import os import os
import select
import socket import socket
import struct import struct
import sys import sys
@@ -58,7 +59,12 @@ import time
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
import numpy as np # One thread for numpy's BLAS and OpenMP, set before numpy loads: it would start one per core
# (8 here) for small arrays that never need them. eyes_pupil keeps OpenCV to one as well.
for _var in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS"):
os.environ.setdefault(_var, "1")
import numpy as np # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import eyes_model # noqa: E402 import eyes_model # noqa: E402
@@ -82,6 +88,15 @@ GAP = 3.0 # s without frames: the headset was off, and may sit diffe
CLICK_BEFORE = 0.3 # a click's frames: the 300 ms before it (like the probe's fixation) CLICK_BEFORE = 0.3 # a click's frames: the 300 ms before it (like the probe's fixation)
CALIB_MIN = 15 # frames an eye needs in a calibration dot's window CALIB_MIN = 15 # frames an eye needs in a calibration dot's window
CALIB_SPREAD = 4.0 # px: more than this and the eye moved during the dot CALIB_SPREAD = 4.0 # px: more than this and the eye moved during the dot
# Waiting for frames. They come only as a counter in shared memory, so there's nothing to block
# on: sleep until a camera's next frame is due, then look every POLL. ft-eyegrab passes each one
# on within a ms or two of when its camera starts the one after, so they arrive 11.1 ms apart,
# give or take that.
PERIOD = 1 / 90 # s between a camera's frames
EARLY = 0.002 # start looking this long before a frame is due
POLL = 0.001 # then this often until it comes
STALLED = 0.1 # s without a frame: that camera stopped (headset off), and isn't waited for
IDLE_POLL = 0.02 # how often to look while both are stopped
class Cams: class Cams:
@@ -352,7 +367,7 @@ def wait_for_cams(sock, tracker, want):
return Cams() return Cams()
except (OSError, ValueError, RuntimeError): except (OSError, ValueError, RuntimeError):
serve(sock, tracker) serve(sock, tracker)
time.sleep(0.2) select.select([sock], [], [], 0.2)
def serve(sock, tracker): def serve(sock, tracker):
@@ -372,7 +387,24 @@ def serve(sock, tracker):
pass pass
def below_steamvr():
"""Nice 10 and SCHED_BATCH, for this thread and those it starts. ft-eyes runs in the dev
container's podman scope, where the gaze service's unit doesn't reach it, so it ran at
nice 0 on the cores vrcompositor and vrserver use. Batch also lets a waking ft-eyes wait
for the running task's turn instead of taking the core: a frame a few ms late costs the
gaze little, a late compositor frame costs a dropped frame in the headset."""
try:
os.setpriority(os.PRIO_PROCESS, 0, max(os.getpriority(os.PRIO_PROCESS, 0), 10))
except OSError as e:
print(f"ft-eyes: nice: {e}", file=sys.stderr, flush=True)
try:
os.sched_setscheduler(0, os.SCHED_BATCH, os.sched_param(0))
except (OSError, AttributeError) as e:
print(f"ft-eyes: SCHED_BATCH: {e}", file=sys.stderr, flush=True)
def main(): def main():
below_steamvr()
verbose = "-v" in sys.argv verbose = "-v" in sys.argv
if "--watch-stdin" in sys.argv: if "--watch-stdin" in sys.argv:
# Run by ft-gazed through distrobox, which doesn't pass a stop on: quit when our # Run by ft-gazed through distrobox, which doesn't pass a stop on: quit when our
@@ -398,6 +430,7 @@ def main():
print("ft-eyes: frames found, tracking", file=sys.stderr, flush=True) print("ft-eyes: frames found, tracking", file=sys.stderr, flush=True)
seen = [cams.count(0), cams.count(1)] seen = [cams.count(0), cams.count(1)]
report = time.monotonic() report = time.monotonic()
arrived = [0.0, 0.0] # when each camera's newest frame was seen (monotonic)
while True: while True:
serve(sock, tracker) serve(sock, tracker)
want() want()
@@ -407,6 +440,7 @@ def main():
if n == seen[c]: if n == seen[c]:
continue continue
seen[c] = n seen[c] = n
arrived[c] = time.monotonic()
got = cams.frame(c, n - 1) # only the newest: never fall behind got = cams.frame(c, n - 1) # only the newest: never fall behind
if got: if got:
eyes[c].feed(*got) eyes[c].feed(*got)
@@ -425,8 +459,6 @@ def main():
shifts += [float(v) for v in e.shift.value] shifts += [float(v) for v in e.shift.value]
pupils += [e.gaze[3], e.gaze[4]] if fresh else [math.nan, math.nan] pupils += [e.gaze[3], e.gaze[4]] if fresh else [math.nan, math.nan]
out.write(latest, (yaw, pitch), flags, per, shifts, pupils) out.write(latest, (yaw, pitch), flags, per, shifts, pupils)
else:
time.sleep(0.001)
now = time.monotonic() now = time.monotonic()
if now - report >= 5: if now - report >= 5:
if verbose: if verbose:
@@ -444,6 +476,12 @@ def main():
print("ft-eyes: frames went away; waiting", file=sys.stderr, flush=True) print("ft-eyes: frames went away; waiting", file=sys.stderr, flush=True)
cams = wait_for_cams(sock, tracker, want) cams = wait_for_cams(sock, tracker, want)
seen = [cams.count(0), cams.count(1)] seen = [cams.count(0), cams.count(1)]
# Until the next frame is due (a command on the socket wakes us sooner).
wait = IDLE_POLL
for c in (0, 1):
if now - arrived[c] < STALLED:
wait = min(wait, max(arrived[c] + PERIOD - EARLY - now, POLL))
select.select([sock], [], [], wait)
if __name__ == "__main__": if __name__ == "__main__":
+15
View File
@@ -582,6 +582,17 @@ public:
for (const auto &e : entries_) keys.push_back(e.key); for (const auto &e : entries_) keys.push_back(e.key);
return keys; return keys;
} }
// A panel just made by another Frametop program (ft-screens: a floating window's menu, the
// keyboard): known at once, without waiting for the next read ("overlay <key>").
void Add(const std::string &key) {
{
std::lock_guard<std::mutex> guard(lock_);
for (const auto &e : entries_)
if (e.key == key) return;
entries_.push_back({key, key, true});
}
++generation_;
}
// Goes up by one each time the list is read, so the main loop knows to look the keys up again. // Goes up by one each time the list is read, so the main loop knows to look the keys up again.
unsigned Generation() const { return generation_; } unsigned Generation() const { return generation_; }
// Answer `to` with {"t":"overlays","list":[{"key","name","visible"}...]} after the next refresh. // Answer `to` with {"t":"overlays","list":[{"key","name","visible"}...]} after the next refresh.
@@ -1522,6 +1533,10 @@ int main() {
reply(controllerButtons.Status()); reply(controllerButtons.Status());
continue; continue;
} }
if (std::strncmp(buf, "overlay ", 8) == 0 && std::strncmp(buf + 8, "frametop.", 9) == 0) {
overlays.Add(buf + 8);
continue;
}
if (std::strncmp(buf, "overlays", 8) == 0) { if (std::strncmp(buf, "overlays", 8) == 0) {
overlays.Request(sender, senderLen); // answered from the list's thread overlays.Request(sender, senderLen); // answered from the list's thread
continue; continue;
+10 -2
View File
@@ -306,14 +306,19 @@ int main() {
std::printf("displays on: %s\n", why.c_str()); std::printf("displays on: %s\n", why.c_str());
}; };
// SteamVR is asked every 100 ms (events, the activity level, poses: IPC calls to
// vrserver). Input wakes the loop too, to wake the displays at once, but a moving mouse
// sends hundreds of events a second, so those wakes only drain the devices.
constexpr auto kTick = std::chrono::milliseconds(100);
auto lastVr = now - kTick;
std::vector<pollfd> fds; std::vector<pollfd> fds;
while (!stopSignal) { while (!stopSignal) {
// Sleep until input or 100 ms: input wakes the displays at once, poses every tick.
fds.clear(); fds.clear();
fds.push_back({ctl, POLLIN, 0}); fds.push_back({ctl, POLLIN, 0});
for (auto &[path, d] : inputs) for (auto &[path, d] : inputs)
if (d.fd >= 0) fds.push_back({d.fd, POLLIN, 0}); if (d.fd >= 0) fds.push_back({d.fd, POLLIN, 0});
poll(fds.data(), fds.size(), 100); const auto wait = std::chrono::ceil<std::chrono::milliseconds>(kTick - (Clock::now() - lastVr));
poll(fds.data(), fds.size(), std::clamp<int>(wait.count(), 0, kTick.count()));
now = Clock::now(); now = Clock::now();
const bool grace = now < graceUntil; const bool grace = now < graceUntil;
std::string use; // why this tick counts as use std::string use; // why this tick counts as use
@@ -329,6 +334,8 @@ int main() {
lastConf = now; lastConf = now;
} }
if (now - lastVr >= kTick) {
lastVr = now;
vr::VREvent_t ev; vr::VREvent_t ev;
while (sys->PollNextEvent(&ev, sizeof ev)) { while (sys->PollNextEvent(&ev, sizeof ev)) {
if (ev.eventType == vr::VREvent_Quit) { if (ev.eventType == vr::VREvent_Quit) {
@@ -395,6 +402,7 @@ int main() {
use = "turned on elsewhere"; use = "turned on elsewhere";
std::printf("displays on: turned on elsewhere\n"); std::printf("displays on: turned on elsewhere\n");
} }
}
if (!use.empty()) { if (!use.empty()) {
lastUse = now; lastUse = now;
turnOn(use); turnOn(use);
+15 -3
View File
@@ -83,6 +83,7 @@ class Window(Adw.ApplicationWindow):
self.status = {} self.status = {}
self.revealed = False self.revealed = False
self.setting = False # the switch is being set from the status, not by the user self.setting = False # the switch is being set from the status, not by the user
self.asking = False # a status check is running
self.toasts = Adw.ToastOverlay() self.toasts = Adw.ToastOverlay()
view = Adw.ToolbarView() view = Adw.ToolbarView()
@@ -139,16 +140,27 @@ class Window(Adw.ApplicationWindow):
"/tmp/frametop-remote.log and /tmp/frametop-vnc.log.") "/tmp/frametop-remote.log and /tmp/frametop-vnc.log.")
page.add(about) page.add(about)
# Each check runs remote-ctl.sh (bash, curl, and python3 for the tailnet name), so
# it runs every 5 seconds, when the window comes to the front, and after a change.
self.refresh() self.refresh()
GLib.timeout_add_seconds(2, self.refresh) GLib.timeout_add_seconds(5, self.refresh)
self.connect("notify::is-active", lambda win, _param: win.is_active() and win.refresh())
# --- status --- # --- status ---
def refresh(self): def refresh(self):
if not self.asking:
self.asking = True
proc = Gio.Subprocess.new([CTL, "status"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE) proc = Gio.Subprocess.new([CTL, "status"], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE)
proc.communicate_utf8_async(None, None, self.on_status) proc.communicate_utf8_async(None, None, self.on_status)
return True return True
def refresh_soon(self):
"""Again in a moment: remote access takes a second or two to start or stop."""
self.refresh()
GLib.timeout_add_seconds(2, lambda: self.refresh() and False)
def on_status(self, proc, result): def on_status(self, proc, result):
self.asking = False
try: try:
_, out, _ = proc.communicate_utf8_finish(result) _, out, _ = proc.communicate_utf8_finish(result)
except GLib.Error: except GLib.Error:
@@ -197,7 +209,7 @@ class Window(Adw.ApplicationWindow):
self.toast("Remote access turns on at the next desktop restart") self.toast("Remote access turns on at the next desktop restart")
else: else:
self.toast("Remote access on") self.toast("Remote access on")
self.refresh() self.refresh_soon()
def on_reveal(self, button): def on_reveal(self, button):
self.revealed = button.get_active() self.revealed = button.get_active()
@@ -221,7 +233,7 @@ class Window(Adw.ApplicationWindow):
if self.status.get("running") == "1": if self.status.get("running") == "1":
subprocess.run([CTL, "restart"], stdin=subprocess.DEVNULL, capture_output=True) subprocess.run([CTL, "restart"], stdin=subprocess.DEVNULL, capture_output=True)
self.toast("New password set" + (": remote access restarted" if self.status.get("running") == "1" else "")) self.toast("New password set" + (": remote access restarted" if self.status.get("running") == "1" else ""))
self.refresh() self.refresh_soon()
def copy(self, text, done): def copy(self, text, done):
if text: if text:
+1 -1
View File
@@ -23,6 +23,6 @@ $cxx -c -o build/handcut.o handcut.cpp
$cxx -c -o build/handtest.o handtest.cpp $cxx -c -o build/handtest.o handtest.cpp
vrlibs="$(pkg-config --libs egl glesv2 gbm) -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64" vrlibs="$(pkg-config --libs egl glesv2 gbm) -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64"
g++ -o build/ft-screens build/compositor.o build/vr.o build/keyboard.o build/handcut.o \ g++ -o build/ft-screens build/compositor.o build/vr.o build/keyboard.o build/handcut.o \
$(pkg-config --libs wlroots-0.20 wayland-server xkbcommon) $vrlibs $(pkg-config --libs wlroots-0.20 wayland-server xkbcommon pixman-1) $vrlibs
g++ -o build/ft-handtest build/handtest.o build/handcut.o $vrlibs g++ -o build/ft-handtest build/handtest.o build/handcut.o $vrlibs
echo "built build/ft-screens build/ft-handtest"' echo "built build/ft-screens build/ft-handtest"'
+168 -14
View File
@@ -13,7 +13,7 @@
// scale first ("scale <screen> <s>", from ft-layout). // scale first ("scale <screen> <s>", from ft-layout).
// //
// Usage: ft-screens [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]... // Usage: ft-screens [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]...
// [--spares N] [-- COMMAND ARGS...] // [--spares N] [--rates F,V,H] [-- COMMAND ARGS...]
// --socket Wayland socket name in $XDG_RUNTIME_DIR (default ft-screens-0) // --socket Wayland socket name in $XDG_RUNTIME_DIR (default ft-screens-0)
// --control the control socket's abstract name (default ft_screens) // --control the control socket's abstract name (default ft_screens)
// --no-vr run without SteamVR, for tests next to the running desktop: no panels, no // --no-vr run without SteamVR, for tests next to the running desktop: no panels, no
@@ -22,6 +22,8 @@
// --screen one per screen, in KWin's order (default: 3440x1440@2.4) // --screen one per screen, in KWin's order (default: 3440x1440@2.4)
// --spares KWin's outputs after the screens: spares for floating windows (ft-floatd // --spares KWin's outputs after the screens: spares for floating windows (ft-floatd
// turns them on and sizes them; see docs/floating-windows.md) // turns them on and sizes them; see docs/floating-windows.md)
// --rates frame rates in Hz for screens you look at, the rest you see, and hidden ones
// (0: every display frame; default 0,15,1; see frame_interval)
// COMMAND run with WAYLAND_DISPLAY set to our socket (e.g. the Frametop session) // COMMAND run with WAYLAND_DISPLAY set to our socket (e.g. the Frametop session)
// Runs in the dev container (wlroots 0.20); KWin connects from the host. // Runs in the dev container (wlroots 0.20); KWin connects from the host.
#define _GNU_SOURCE #define _GNU_SOURCE
@@ -33,7 +35,9 @@
#include <string.h> #include <string.h>
#include <stddef.h> #include <stddef.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <sys/resource.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/timerfd.h>
#include <sys/un.h> #include <sys/un.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <time.h> #include <time.h>
@@ -61,6 +65,12 @@
#include "controller-click.h" #include "controller-click.h"
#define MAX_SCREENS 24 // screens and spare outputs #define MAX_SCREENS 24 // screens and spare outputs
// A screen counts as playing a video while its last VIDEO_COMMITS commits each redrew at
// least VIDEO_AREA percent of it, at VIDEO_HZ or more; for VIDEO_HOLD ms after that stops.
#define VIDEO_COMMITS 8
#define VIDEO_AREA 6
#define VIDEO_HZ 10
#define VIDEO_HOLD 1500
struct config { struct config {
int width, height; int width, height;
@@ -80,6 +90,13 @@ struct screen {
int buffer_width, buffer_height; int buffer_width, buffer_height;
struct wlr_xdg_toplevel_decoration_v1 *decoration; // answered on the first commit struct wlr_xdg_toplevel_decoration_v1 *decoration; // answered on the first commit
struct wl_listener commit, destroy, decoration_destroy, set_title; struct wl_listener commit, destroy, decoration_destroy, set_title;
// Its frame rate (see tick): when its last frame callback went (ms), and what its recent
// commits looked like, to tell a video playing on it.
uint32_t frame_sent;
uint32_t commit_ms[VIDEO_COMMITS];
unsigned commit_at;
unsigned big; // a bit per recent commit: it redrew at least VIDEO_AREA percent
uint32_t video_until; // counts as a video until then (ms)
}; };
// Per client buffer: forget its import when it goes away. // Per client buffer: forget its import when it goes away.
@@ -104,6 +121,14 @@ struct server {
int spares; int spares;
struct wl_list buffers; // tracked_buffer struct wl_list buffers; // tracked_buffer
struct wl_event_source *tick; struct wl_event_source *tick;
int tick_fd; // timerfd, at absolute times in step with the display's vsync (see schedule)
int64_t period_ns, base_ns, synced_ns; // the display's frame time, a tick time, last sync
double phase_ms; // ticks come this long after a vsync
// Frame rates in Hz for each attention level (0: every display frame), and a remote
// viewer's lease: until then (ms) every screen gets full rate ("watch").
int rate[3];
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 screen *pointer_focus;
struct ft_controller_click controller_click; struct ft_controller_click controller_click;
pid_t child; pid_t child;
@@ -149,6 +174,25 @@ static void track_buffer(struct server *s, struct wlr_buffer *buffer) {
// ---------------------------------------------------------------- screens // ---------------------------------------------------------------- screens
// 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
// small part, and a page changing now and then doesn't keep coming.
static void note_damage(struct screen *sc, struct wlr_surface *surface, struct wlr_buffer *buffer) {
int n = 0;
const pixman_box32_t *r = pixman_region32_rectangles(&surface->buffer_damage, &n);
int64_t area = 0;
for (int i = 0; i < n; ++i) area += (int64_t)(r[i].x2 - r[i].x1) * (r[i].y2 - r[i].y1);
const bool big = area * 100 >= (int64_t)buffer->width * buffer->height * VIDEO_AREA;
const unsigned all = (1u << VIDEO_COMMITS) - 1;
sc->big = ((sc->big << 1) | big) & all;
const uint32_t t = now_ms();
const uint32_t oldest = sc->commit_ms[sc->commit_at]; // the commit VIDEO_COMMITS ago
sc->commit_ms[sc->commit_at] = t;
sc->commit_at = (sc->commit_at + 1) % VIDEO_COMMITS;
if (sc->big == all && t - oldest <= VIDEO_COMMITS * 1000 / VIDEO_HZ) sc->video_until = t + VIDEO_HOLD;
}
static void screen_commit(struct wl_listener *l, void *data) { static void screen_commit(struct wl_listener *l, void *data) {
struct screen *sc = wl_container_of(l, sc, commit); struct screen *sc = wl_container_of(l, sc, commit);
struct wlr_xdg_surface *xdg = sc->toplevel->base; struct wlr_xdg_surface *xdg = sc->toplevel->base;
@@ -172,6 +216,7 @@ static void screen_commit(struct wl_listener *l, void *data) {
if (!buffer) return; if (!buffer) return;
sc->frame_pending = true; sc->frame_pending = true;
++sc->commits; ++sc->commits;
note_damage(sc, xdg->surface, buffer);
sc->buffer_width = buffer->width, sc->buffer_height = buffer->height; sc->buffer_width = buffer->width, sc->buffer_height = buffer->height;
if (buffer == sc->held) return; if (buffer == sc->held) return;
struct wlr_dmabuf_attributes a; struct wlr_dmabuf_attributes a;
@@ -381,12 +426,64 @@ static void keys_update(struct server *s) {
offsetof(struct sockaddr_un, sun_path) + 1 + sizeof name - 1); offsetof(struct sockaddr_un, sun_path) + 1 + sizeof name - 1);
} }
// Every ~11 ms (90 Hz): SteamVR events, and frame callbacks for screens that committed. While // How often a screen gets its frame callback (ms; 0 every tick). KWin draws a screen only
// Frametop is paused for a VR game (everything hidden), every 100 ms, and the frame callbacks // after its callback, and its apps wait for theirs, so this is the screen's frame rate:
// once a second: KWin draws a screen only after its callback, and its apps wait for theirs, so // full where you look (ft_vr_screen_attention) and for a video, lower for the rest of what
// the desktop hardly draws until it's resumed. // you see, and about once a second for what you don't (hidden, behind you, paused for a VR
static int tick(void *data) { // game). A screen nothing changes on costs nothing at any rate: KWin commits only when
// something on it moved. A remote viewer ("watch") sees every screen at full rate.
static uint32_t frame_interval(struct server *s, struct screen *sc, uint32_t t) {
if ((int32_t)(s->watch_until - t) > 0) return 0;
enum ft_attention a = ft_vr_screen_attention(sc->index);
if (a == FT_IN_VIEW && (int32_t)(sc->video_until - t) > 0) a = FT_FOCUSED;
if (a != FT_FOCUSED && t - s->typed_ms < 1500 &&
s->seat->keyboard_state.focused_surface == sc->toplevel->base->surface)
a = FT_FOCUSED; // typing on it while looking elsewhere
const int hz = s->rate[a];
return hz > 0 ? 1000 / hz : 0;
}
static int64_t mono_ns(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1000000000LL + ts.tv_nsec;
}
// Ticks come once per display frame, phase_ms after its vsync: KWin gets its frame callbacks
// early in the frame and has the rest of it to draw before vrcompositor takes the panels.
// The vsync is read from SteamVR once a second, and the ticks run at absolute times between
// reads, so they keep their place (a timer set again after each tick, as before, slid
// through the frame and came about 85 times a second at 90 Hz). Without SteamVR's timing
// they still come at the display's rate (90 Hz until known). While paused, every 100 ms.
static void schedule(struct server *s) {
const int64_t now = mono_ns();
int64_t next;
if (ft_vr_paused()) {
next = now + 100000000LL;
} else {
double since, hz;
if (now - s->synced_ns >= 1000000000LL) {
s->synced_ns = now;
if (ft_vr_vsync(&since, &hz)) {
const int64_t period = (int64_t)(1e9 / hz);
if (llabs(period - s->period_ns) > 100000) wlr_log(WLR_INFO, "display at %.1f Hz", hz);
s->period_ns = period;
s->base_ns = now - (int64_t)(since * 1e9) + (int64_t)(s->phase_ms * 1e6);
while (s->base_ns > now) s->base_ns -= s->period_ns;
}
}
next = s->base_ns + ((now - s->base_ns) / s->period_ns + 1) * s->period_ns;
}
struct itimerspec its = {.it_value = {.tv_sec = next / 1000000000LL, .tv_nsec = next % 1000000000LL}};
timerfd_settime(s->tick_fd, TFD_TIMER_ABSTIME, &its, NULL);
}
// Each tick: SteamVR events, and frame callbacks for screens that committed and are due.
static int tick(int fd, uint32_t mask, void *data) {
struct server *s = data; struct server *s = data;
uint64_t expirations;
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); ft_vr_poll(handle_vr_event, s);
if (++s->ticks % 9 == 0) keys_update(s); if (++s->ticks % 9 == 0) keys_update(s);
if (s->kb_close_at && s->ticks >= s->kb_close_at) { if (s->kb_close_at && s->ticks >= s->kb_close_at) {
@@ -398,15 +495,17 @@ static int tick(void *data) {
} }
struct timespec now; struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now); clock_gettime(CLOCK_MONOTONIC, &now);
const bool paused = ft_vr_paused(); const uint32_t t = now_ms();
for (int i = 0; i < MAX_SCREENS && (!paused || s->ticks % 10 == 0); ++i) { // Due within half a tick counts as due, so 15 Hz is every 6th tick at 90 Hz, not 7th.
const uint32_t slack = ft_vr_paused() ? 50 : (uint32_t)(s->period_ns / 2000000);
for (int i = 0; i < MAX_SCREENS; ++i) {
struct screen *sc = s->screens[i]; struct screen *sc = s->screens[i];
if (sc && sc->frame_pending) { if (!sc || !sc->frame_pending || t - sc->frame_sent + slack < frame_interval(s, sc, t)) continue;
sc->frame_pending = false; sc->frame_pending = false;
sc->frame_sent = t;
wlr_surface_send_frame_done(sc->toplevel->base->surface, &now); wlr_surface_send_frame_done(sc->toplevel->base->surface, &now);
} }
} schedule(s);
wl_event_source_timer_update(s->tick, paused ? 100 : 11);
return 0; return 0;
} }
@@ -431,6 +530,7 @@ static bool key_held(const struct wlr_keyboard *kb, uint32_t code) {
// One key to the focused screen, through the seat's keyboard so its xkb state and // One key to the focused screen, through the seat's keyboard so its xkb state and
// modifiers stay right. // modifiers stay right.
static void send_key(struct server *s, uint32_t code, int pressed) { static void send_key(struct server *s, uint32_t code, int pressed) {
s->typed_ms = now_ms();
struct wlr_keyboard_key_event ev = { struct wlr_keyboard_key_event ev = {
.time_msec = now_ms(), .keycode = code, .update_state = true, .time_msec = now_ms(), .keycode = code, .update_state = true,
.state = pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED}; .state = pressed ? WL_KEYBOARD_KEY_STATE_PRESSED : WL_KEYBOARD_KEY_STATE_RELEASED};
@@ -584,6 +684,38 @@ static int control_readable(int fd, uint32_t mask, void *data) {
handle_vr_event(&e, s); handle_vr_event(&e, s);
snprintf(reply, sizeof reply, "ok"); snprintf(reply, sizeof reply, "ok");
} }
} else if (sscanf(buf, "watch %d", &value) == 1) {
// A remote viewer is watching for that many seconds (vnc-bridge.sh renews it).
s->watch_until = now_ms() + (uint32_t)(value < 0 ? 0 : value > 60 ? 60 : value) * 1000;
snprintf(reply, sizeof reply, "ok");
} else if (sscanf(buf, "rates %d %d %d", &index, &w, &h) == 3) {
// Frame rates in Hz: focused, in view, hidden (0: every display frame).
if (index < 0 || w < 0 || h < 0 || index > 240 || w > 240 || h > 240) {
snprintf(reply, sizeof reply, "error rates <focused> <in view> <hidden> (Hz, 0 full)");
} else {
s->rate[FT_FOCUSED] = index, s->rate[FT_IN_VIEW] = w, s->rate[FT_HIDDEN] = h;
wlr_log(WLR_INFO, "frame rates: focused %d, in view %d, hidden %d (0 full)", index, w, h);
snprintf(reply, sizeof reply, "ok");
}
} else if (sscanf(buf, "phase %lf", &scale) == 1) {
// Ticks this long after the vsync (ms), for tuning.
if (!(scale >= 0 && scale < 20)) snprintf(reply, sizeof reply, "error phase <ms after vsync>");
else s->phase_ms = scale, s->synced_ns = 0, snprintf(reply, sizeof reply, "ok");
} else if (strcmp(buf, "rates?") == 0) {
// "ok <focused> <in view> <hidden> <display Hz> <phase ms> <watched>" and a line per
// screen: "<screen> hidden|view|focused <ms between frames> <video 0|1>".
static const char *names[] = {"hidden", "view", "focused"};
const uint32_t t = now_ms();
int at = snprintf(reply, sizeof reply, "ok %d %d %d %.1f %.1f %d", s->rate[FT_FOCUSED],
s->rate[FT_IN_VIEW], s->rate[FT_HIDDEN], 1e9 / s->period_ns, s->phase_ms,
(int32_t)(s->watch_until - t) > 0);
for (int i = 0; i < MAX_SCREENS && at < (int)sizeof reply; ++i) {
struct screen *sc = s->screens[i];
if (!sc) continue;
at += snprintf(reply + at, sizeof reply - at, "\n%d %s %u %d", i + 1,
names[ft_vr_screen_attention(i)], frame_interval(s, sc, t),
(int32_t)(sc->video_until - t) > 0);
}
} else if (strcmp(buf, "toplevels") == 0) { } else if (strcmp(buf, "toplevels") == 0) {
int n = 0, at = 0; int n = 0, at = 0;
for (int i = 0; i < MAX_SCREENS; ++i) n += s->screens[i] != NULL; for (int i = 0; i < MAX_SCREENS; ++i) n += s->screens[i] != NULL;
@@ -666,6 +798,9 @@ int main(int argc, char **argv) {
s.controller_click.threshold = 8; s.controller_click.threshold = 8;
for (int i = 0; i < MAX_SCREENS; ++i) s.scale[i] = 1; for (int i = 0; i < MAX_SCREENS; ++i) s.scale[i] = 1;
s.kb_screen = -1; s.kb_screen = -1;
s.rate[FT_FOCUSED] = 0, s.rate[FT_IN_VIEW] = 15, s.rate[FT_HIDDEN] = 1;
s.period_ns = 1000000000LL / 90;
s.phase_ms = 1;
const char *socket_name = "ft-screens-0", *control_name = "ft_screens"; const char *socket_name = "ft-screens-0", *control_name = "ft_screens";
char **command = NULL; char **command = NULL;
s.vr = true; s.vr = true;
@@ -678,6 +813,11 @@ int main(int argc, char **argv) {
s.spares = atoi(argv[++i]); s.spares = atoi(argv[++i]);
} else if (strcmp(argv[i], "--no-vr") == 0) { } else if (strcmp(argv[i], "--no-vr") == 0) {
s.vr = false; s.vr = false;
} else if (strcmp(argv[i], "--rates") == 0 && i + 1 < argc) {
if (sscanf(argv[++i], "%d,%d,%d", &s.rate[FT_FOCUSED], &s.rate[FT_IN_VIEW], &s.rate[FT_HIDDEN]) != 3) {
fprintf(stderr, "bad --rates %s (want FOCUSED,IN_VIEW,HIDDEN in Hz, 0 full)\n", argv[i]);
return 2;
}
} else if (strcmp(argv[i], "--screen") == 0 && i + 1 < argc && s.n_config < MAX_SCREENS) { } else if (strcmp(argv[i], "--screen") == 0 && i + 1 < argc && s.n_config < MAX_SCREENS) {
struct config *c = &s.config[s.n_config]; struct config *c = &s.config[s.n_config];
c->metres = 0; c->metres = 0;
@@ -693,7 +833,7 @@ int main(int argc, char **argv) {
} else { } else {
fprintf(stderr, fprintf(stderr,
"usage: %s [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]... " "usage: %s [--socket NAME] [--control NAME] [--no-vr] [--screen WxH@METRES]... "
"[--spares N] [-- COMMAND ARGS...]\n", "[--spares N] [--rates F,V,H] [-- COMMAND ARGS...]\n",
argv[0]); argv[0]);
return 2; return 2;
} }
@@ -745,8 +885,10 @@ int main(int argc, char **argv) {
s.relay_fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); s.relay_fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
wl_event_loop_add_fd(s.loop, control, WL_EVENT_READABLE, control_readable, &s); wl_event_loop_add_fd(s.loop, control, WL_EVENT_READABLE, control_readable, &s);
s.tick = wl_event_loop_add_timer(s.loop, tick, &s); s.tick_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
wl_event_source_timer_update(s.tick, 11); s.base_ns = mono_ns();
s.tick = wl_event_loop_add_fd(s.loop, s.tick_fd, WL_EVENT_READABLE, tick, &s);
schedule(&s);
wl_event_loop_add_signal(s.loop, SIGINT, stop, &s); wl_event_loop_add_signal(s.loop, SIGINT, stop, &s);
wl_event_loop_add_signal(s.loop, SIGTERM, stop, &s); wl_event_loop_add_signal(s.loop, SIGTERM, stop, &s);
wl_event_loop_add_signal(s.loop, SIGCHLD, child_exited, &s); wl_event_loop_add_signal(s.loop, SIGCHLD, child_exited, &s);
@@ -761,6 +903,18 @@ int main(int argc, char **argv) {
} }
} }
// Ahead of the desktop's programs for the CPU, after the session started (it would
// inherit it): KWin waits for our ticks to draw. SteamOS lets users go to nice -8
// (RLIMIT_NICE 28) once they raise their soft limit; without that, as before.
if (s.vr) {
struct rlimit rl;
if (getrlimit(RLIMIT_NICE, &rl) == 0 && rl.rlim_cur < rl.rlim_max) {
rl.rlim_cur = rl.rlim_max;
setrlimit(RLIMIT_NICE, &rl);
}
if (setpriority(PRIO_PROCESS, 0, -5) != 0) wlr_log(WLR_INFO, "can't raise our priority (nice -5); running at 0");
}
wl_display_run(s.display); wl_display_run(s.display);
wlr_log(WLR_INFO, "stopping"); wlr_log(WLR_INFO, "stopping");
+106 -3
View File
@@ -46,7 +46,8 @@
// - paused ("pause on", from the input relay when Frametop pauses for a VR game, // - paused ("pause on", from the input relay when Frametop pauses for a VR game,
// input/game_pause.py): every screen and floating window hides whatever the mode, the // input/game_pause.py): every screen and floating window hides whatever the mode, the
// hotkey, or the dashboard says, and compositor.c gives KWin a frame callback once a // hotkey, or the dashboard says, and compositor.c gives KWin a frame callback once a
// second instead of 90 times, so KWin and its apps hardly draw. "pause off" undoes it. // second, as for any hidden screen, so KWin and its apps hardly draw. "pause off" undoes
// it.
// - hand cutouts (handcut.cpp): where ft-hands (hands/) tracks a hand between an eye and a // - hand cutouts (handcut.cpp): where ft-hands (hands/) tracks a hand between an eye and a
// screen, that eye sees through the screen (to Room View). Only then is the screen // screen, that eye sees through the screen (to Room View). Only then is the screen
// drawn by us, into a side-by-side buffer (one half per eye); otherwise its client // drawn by us, into a side-by-side buffer (one half per eye); otherwise its client
@@ -291,6 +292,11 @@ struct Screen {
long resizeSent = 0; // g_tick of the last resize request (they're throttled) long resizeSent = 0; // g_tick of the last resize request (they're throttled)
int resizeW = 0, resizeH = 0; // ...and its size int resizeW = 0, resizeH = 0; // ...and its size
std::map<int, Sub> subs; std::map<int, Sub> subs;
// Attention (UpdateAttention): what ft_vr_screen_attention answers, and until when (ms)
// it stays focused or in view after the last reason for it.
ft_attention attention = FT_FOCUSED;
int64_t inputMs = INT64_MIN / 2; // the last pointer event on it or its controls
int64_t focusUntil = 0, viewUntil = 0;
double heightMetres() const { double heightMetres() const {
if (floating && cropW > 0) return metres * cropH / cropW; if (floating && cropW > 0) return metres * cropH / cropW;
return width > 0 ? metres * height / width : metres * 9 / 16; return width > 0 ? metres * height / width : metres * 9 / 16;
@@ -816,6 +822,66 @@ void UpdateVisibility() {
} }
} }
// Attention, for each screen's frame rate (compositor.c gives KWin frame callbacks at a
// rate for each level): focused while you look at the screen (within kFocusAngle of where
// your head points), a laser or the mouse is on it, or it's being carried; in view while
// any of it is within kViewAngle; hidden otherwise, or while it isn't shown. A level stays
// for a moment after its reason goes, so a glance away doesn't make it stutter, and goes up
// at once.
constexpr double kFocusAngle = 12, kViewAngle = 60; // degrees
constexpr int64_t kFocusLinger = 1500, kViewLinger = 500, kInputFocus = 1500; // ms
bool RayOnPlane(const Mat &p, const Mat &d, double *x, double *y);
int64_t NowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now().time_since_epoch()).count();
}
// The smallest angle between where the head points and the screen: to the point of its
// rectangle nearest where the head's ray meets its plane, its centre, and its corners. A
// curved screen counts as flat; the angles hardly differ.
double AngleToScreen(const Screen &s, const Mat &p, const Mat &head) {
const double hw = s.metres / 2, hh = s.heightMetres() / 2;
double f[3];
Column(head, 2, f); // the head's +Z points backward
auto angleTo = [&](double u, double v) {
double to[3];
for (int i = 0; i < 3; ++i) to[i] = p.m[i][3] + u * p.m[i][0] + v * p.m[i][1] - head.m[i][3];
const double len = std::sqrt(Dot3(to, to)) + 1e-9;
return std::acos(std::clamp(-Dot3(f, to) / len, -1.0, 1.0)) * 180 / M_PI;
};
double best = 180, x, y;
if (RayOnPlane(p, head, &x, &y)) best = angleTo(std::clamp(x, -hw, hw), std::clamp(y, -hh, hh));
for (double u : {-hw, 0.0, hw})
for (double v : {-hh, 0.0, hh}) best = std::min(best, angleTo(u, v));
return best;
}
void UpdateAttention() {
const int64_t now = NowMs();
Mat head;
const bool haveHead = DevicePose(vr::k_unTrackedDeviceIndex_Hmd, &head);
for (auto &[i, s] : g_screens) {
if (!s.visible) {
s.attention = FT_HIDDEN;
s.focusUntil = s.viewUntil = 0;
continue;
}
bool focus = s.drag != Drag::None || now - s.inputMs < kInputFocus, view = focus;
Mat p;
if (!haveHead) {
view = true; // nothing to go by
} else if (ScreenPose(s, &p)) {
const double a = AngleToScreen(s, p, head);
focus = focus || a <= kFocusAngle;
view = view || a <= kViewAngle;
}
if (focus) s.focusUntil = now + kFocusLinger;
if (view) s.viewUntil = now + kViewLinger;
s.attention = now < s.focusUntil ? FT_FOCUSED : now < s.viewUntil ? FT_IN_VIEW : FT_HIDDEN;
}
}
// Controllers' lasers on the screens (see the top): the flag follows the mode and whether a // Controllers' lasers on the screens (see the top): the flag follows the mode and whether a
// VR game runs. // VR game runs.
@@ -900,6 +966,19 @@ void SendFloat(const std::string &msg) {
std::printf("to ft-floatd: %s\n", msg.c_str()); std::printf("to ft-floatd: %s\n", msg.c_str());
} }
// A new panel: the pointer helper reads SteamVR's list of panels only every 20 s, so it's told
// at once ("overlay <key>"), or the mouse couldn't click a menu until then.
void AnnounceOverlay(const char *key) {
static const int fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
const char name[] = "ft_pointer_helper";
std::memcpy(addr.sun_path + 1, name, sizeof name - 1);
const std::string msg = std::string("overlay ") + key;
sendto(fd, msg.data(), msg.size(), MSG_DONTWAIT, reinterpret_cast<sockaddr *>(&addr),
socklen_t(offsetof(sockaddr_un, sun_path) + 1 + sizeof name - 1));
}
// Where a device's ray meets the screen's plane, in the screen's x (right) and y (up), // Where a device's ray meets the screen's plane, in the screen's x (right) and y (up),
// metres from its centre. // metres from its centre.
bool RayOnPlane(const Mat &p, const Mat &d, double *x, double *y) { bool RayOnPlane(const Mat &p, const Mat &d, double *x, double *y) {
@@ -1247,6 +1326,7 @@ void SetSub(Screen &s, int index, int k, int x, int y, int w, int h) {
vr::VROverlay()->SetOverlayFlag(sub.overlay, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true); vr::VROverlay()->SetOverlayFlag(sub.overlay, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true);
vr::VROverlay()->SetOverlayFlag(sub.overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, s.lasers); vr::VROverlay()->SetOverlayFlag(sub.overlay, vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible, s.lasers);
vr::VROverlay()->SetOverlaySortOrder(sub.overlay, 5); vr::VROverlay()->SetOverlaySortOrder(sub.overlay, 5);
AnnounceOverlay(key);
it = s.subs.emplace(k, sub).first; it = s.subs.emplace(k, sub).first;
if (s.shown) { if (s.shown) {
auto imp = g_imports.find(s.shown); auto imp = g_imports.find(s.shown);
@@ -1507,7 +1587,7 @@ void UpdateSteamInFront() {
g_keyboardAside = true; g_keyboardAside = true;
} else if (!front && g_keyboardAside) { } else if (!front && g_keyboardAside) {
g_keyboardAside = false; g_keyboardAside = false;
keyboard::Show(g_asidePose); if (keyboard::Show(g_asidePose)) AnnounceOverlay("frametop.keyboard");
} }
} }
@@ -1577,6 +1657,24 @@ 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_screens_shown(void) { return g_vr && ModeVisible(); }
bool ft_vr_paused(void) { return g_paused; } bool ft_vr_paused(void) { return g_paused; }
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;
}
bool ft_vr_vsync(double *since, double *hz) {
if (!g_vr) return false;
float s = 0;
uint64_t frame = 0;
if (!vr::VRSystem()->GetTimeSinceLastVsync(&s, &frame)) return false;
vr::ETrackedPropertyError err = vr::TrackedProp_Success;
const float f = vr::VRSystem()->GetFloatTrackedDeviceProperty(vr::k_unTrackedDeviceIndex_Hmd,
vr::Prop_DisplayFrequency_Float, &err);
if (err != vr::TrackedProp_Success || !(f >= 30 && f <= 240) || !(s >= 0 && s < 1)) return false;
*since = s, *hz = f;
return true;
}
} // extern "C" } // extern "C"
namespace { namespace {
@@ -1735,6 +1833,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
auto panelEvent = [&](const vr::VREvent_t &ev, bool sub) { auto panelEvent = [&](const vr::VREvent_t &ev, bool sub) {
ft_event e{}; ft_event e{};
e.screen = index; e.screen = index;
if (ev.eventType != vr::VREvent_FocusLeave) s.inputMs = NowMs();
auto at = [&] { s.ToBuffer(ev.data.mouse.x, ev.data.mouse.y, &e.x, &e.y); }; auto at = [&] { s.ToBuffer(ev.data.mouse.x, ev.data.mouse.y, &e.x, &e.y); };
switch (ev.eventType) { switch (ev.eventType) {
case vr::VREvent_MouseMove: case vr::VREvent_MouseMove:
@@ -1787,6 +1886,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
// The controls light up under a laser. // The controls light up under a laser.
auto hover = [&](int k) { auto hover = [&](int k) {
const bool on = ev.eventType == vr::VREvent_MouseMove || ev.eventType == vr::VREvent_FocusEnter; const bool on = ev.eventType == vr::VREvent_MouseMove || ev.eventType == vr::VREvent_FocusEnter;
if (on) s.inputMs = NowMs();
if (!on && ev.eventType != vr::VREvent_FocusLeave) return; if (!on && ev.eventType != vr::VREvent_FocusLeave) return;
if (s.hover[k] != on) s.hover[k] = on, ApplyAlpha(s); if (s.hover[k] != on) s.hover[k] = on, ApplyAlpha(s);
}; };
@@ -1899,6 +1999,7 @@ void ft_vr_poll(void (*handle)(const struct ft_event *, void *), void *data) {
UpdateGame(); UpdateGame();
UpdateArrange(); UpdateArrange();
UpdateVisibility(); UpdateVisibility();
UpdateAttention();
UpdateLasers(); UpdateLasers();
UpdateControls(); UpdateControls();
UpdateGuides(); UpdateGuides();
@@ -1933,7 +2034,9 @@ bool ft_vr_keyboard_show(int index) {
std::printf("keyboard: waiting for Steam to close\n"); std::printf("keyboard: waiting for Steam to close\n");
return true; return true;
} }
return keyboard::Show(FacingPose(at, head)); if (!keyboard::Show(FacingPose(at, head))) return false;
AnnounceOverlay("frametop.keyboard");
return true;
} }
void ft_vr_keyboard_hide(void) { void ft_vr_keyboard_hide(void) {
+8
View File
@@ -38,6 +38,14 @@ int ft_vr_modifiers(uint32_t format, uint64_t *out, int max);
bool ft_vr_screens_shown(void); bool ft_vr_screens_shown(void);
// Frametop is paused for a VR game ("pause on"): everything is hidden, and KWin slows down. // Frametop is paused for a VR game ("pause on"): everything is hidden, and KWin slows down.
bool ft_vr_paused(void); bool ft_vr_paused(void);
// 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.
enum ft_attention { FT_HIDDEN, FT_IN_VIEW, FT_FOCUSED };
enum ft_attention ft_vr_screen_attention(int index);
// The display's refresh rate and the time since its last vsync, in seconds. False without
// them (no SteamVR, or the headset isn't reporting).
bool ft_vr_vsync(double *since, double *hz);
// A panel for screen `index`, width in metres, placed in a row in front of the head. // A panel for screen `index`, width in metres, placed in a row in front of the head.
void ft_vr_screen_create(int index, double metres, int count); void ft_vr_screen_create(int index, double metres, int count);
void ft_vr_screen_destroy(int index); void ft_vr_screen_destroy(int index);
+14 -6
View File
@@ -46,8 +46,9 @@ PACKAGES = {
"deckard-steamvr-rel": "the 3D mouse on the dashboard, on SteamVR Settings, and on a SteamVR " "deckard-steamvr-rel": "the 3D mouse on the dashboard, on SteamVR Settings, and on a SteamVR "
"window's grab bar; picking up a controller; mapped controller buttons; gaze", "window's grab bar; picking up a controller; mapped controller buttons; gaze",
"kwin": "clicks near the far edge of a screen whose scale isn't 1; every screen comes back " "kwin": "clicks near the far edge of a screen whose scale isn't 1; every screen comes back "
"after a desktop restart; floating a window", "after a desktop restart; floating a window; no blur behind the taskbar's menus",
"plasma-workspace": "the taskbar and panels after a desktop restart", "plasma-workspace": "the taskbar and panels after a desktop restart; no DiscoverNotifier or "
"ibus-daemon inside the desktop",
"gamescope": "the headset's volume buttons with nothing focused; typing goes where you last clicked", "gamescope": "the headset's volume buttons with nothing focused; typing goes where you last clicked",
"bluez": "a Bluetooth mouse reconnecting after it sleeps", "bluez": "a Bluetooth mouse reconnecting after it sleeps",
} }
@@ -176,14 +177,21 @@ def check_host():
try: try:
with open("/usr/bin/kwin_wayland", "rb") as f: with open("/usr/bin/kwin_wayland", "rb") as f:
# Qt keeps the option name as a UTF-16 string literal. kwin = f.read()
output_count = "output-count".encode("utf-16-le") in f.read()
except OSError: except OSError:
output_count = False kwin = b""
if output_count: # Qt keeps the option name as a UTF-16 string literal.
if "output-count".encode("utf-16-le") in kwin:
report("ok", "KWin", "has --output-count (one output per screen)") report("ok", "KWin", "has --output-count (one output per screen)")
else: else:
report("FAIL", "KWin", "no --output-count option: the desktop gets one screen at most") report("FAIL", "KWin", "no --output-count option: the desktop gets one screen at most")
# The session turns these built-in effects off by id (blurEnabled, contrastEnabled in kwinrc).
gone = [e for e in ("blur", "contrast") if b"KWin::%s_factory" % e.encode() not in kwin]
if not gone:
report("ok", "KWin effects", "blur and contrast, which the desktop turns off, keep their ids")
else:
report("warn", "KWin effects", f"no built-in {', '.join(gone)} effect: renamed? The desktop's "
"kwinrc may no longer turn it off (session/frametop-session.sh)")
if systemctl("cat", "steamvr.service"): if systemctl("cat", "steamvr.service"):
report("ok", "steamvr.service", "Frametop's services start and stop with it") report("ok", "steamvr.service", "Frametop's services start and stop with it")
+34
View File
@@ -234,6 +234,40 @@ elif [ "$(kreadconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme
kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme --delete kwriteconfig6 --file "$kwinrc" --group org.kde.kdecoration2 --key theme --delete
fi fi
# KWin draws through zink on the headset's GPU, which vrcompositor needs, so the blur and
# background contrast behind panels and menus and the window animations cost frames for
# little. They're off by default in this desktop. Each is written once, before KWin first
# reads it, and only if this desktop's config doesn't have it yet; the marker keeps it
# from coming back, since System Settings deletes a setting put back to KDE's default.
# docs/reference.md says how to turn them on again.
default() { # file group key value
[ -n "$(kreadconfig6 --file "$1" --group "$2" --key "$3")" ] || kwriteconfig6 --file "$1" --group "$2" --key "$3" "$4"
}
frametoprc=$XDG_CONFIG_HOME/frametoprc
if [ "$(kreadconfig6 --file "$frametoprc" --group Defaults --key effects)" != 1 ]; then
default "$kwinrc" Plugins blurEnabled false
default "$kwinrc" Plugins contrastEnabled false
default "$XDG_CONFIG_HOME/kdeglobals" KDE AnimationDurationFactor 0
kwriteconfig6 --file "$frametoprc" --group Defaults --key effects 1
fi
# System autostart entries this desktop doesn't need, hidden for it alone by a copy with
# Hidden=true in its own autostart folder, once and unless there's a file of that name
# already. Discover's update notifier starts Discover itself to check for updates (about
# 600 MB and a share of a core, with Flatpak's helper and AppStream behind it); updates
# come with SteamOS and from Discover in the stock desktop. IBus can't reach the
# desktop's apps: KWin's input method is ft-textinput, and the session drops the
# variables that point apps at IBus or XIM. Deleting the copy brings an entry back.
if [ "$(kreadconfig6 --file "$frametoprc" --group Defaults --key autostart)" != 1 ]; then
for entry in org.kde.discover.notifier ibus; do
src=/etc/xdg/autostart/$entry.desktop dst=$XDG_CONFIG_HOME/autostart/$entry.desktop
[ -r "$src" ] && [ ! -e "$dst" ] || continue
mkdir -p "$(dirname "$dst")"
sed '/^\[Desktop Entry\]$/a Hidden=true' "$src" > "$dst"
done
kwriteconfig6 --file "$frametoprc" --group Defaults --key autostart 1
fi
# Profiles reopen apps (docs/profiles.md), so Plasma's own session restore stays off here; # Profiles reopen apps (docs/profiles.md), so Plasma's own session restore stays off here;
# with both, apps would open twice. # with both, apps would open twice.
kwriteconfig6 --file "$XDG_CONFIG_HOME/ksmserverrc" --group General --key loginMode emptySession kwriteconfig6 --file "$XDG_CONFIG_HOME/ksmserverrc" --group General --key loginMode emptySession
+102 -12
View File
@@ -11,6 +11,16 @@
# screen placed lower or further right. Instead the VNC screen is the primary's size, and # screen placed lower or further right. Instead the VNC screen is the primary's size, and
# the workspace-sized RDP window inside it is shifted so the primary fills it. The pointer # the workspace-sized RDP window inside it is shifted so the primary fills it. The pointer
# maps 1:1. When the layout changes, the VNC screen resizes and the RDP client reconnects. # maps 1:1. When the layout changes, the VNC screen resizes and the RDP client reconnects.
#
# The RDP client runs only while someone watches. While connected, krdp captures and
# H.264-encodes every redraw in software, about 60% of a core, with FreeRDP and Xvnc adding
# about 30% more, even with no VNC viewer. krdp starts its capture per RDP connection and
# stops it when the connection closes, so it idles without one. So FreeRDP starts when a
# VNC client connects (the screen is black for the few seconds that takes) and stops
# VNC_IDLE_SEC (45) seconds after the last one leaves. Xvnc has no hook for clients, so ss
# counts them: whenever Xvnc writes to its log (it logs each connection), every second
# while FreeRDP runs, and every 5 seconds otherwise. The log only wakes this script up;
# what it says doesn't matter.
set -eu set -eu
here=$(dirname "$(readlink -f "$0")") here=$(dirname "$(readlink -f "$0")")
@@ -57,16 +67,51 @@ stop_rdp() { pkill -f "[x]freerdp /v:127.0.0.1:$rdp_port " 2>/dev/null || true;
trap 'stop_rdp; pkill -f "[X]vnc $display " 2>/dev/null || true' EXIT trap 'stop_rdp; pkill -f "[X]vnc $display " 2>/dev/null || true' EXIT
box bash -c 'vncpasswd -f < "$1/vnc-password" > "$1/vnc-passwd.bin" && chmod 600 "$1/vnc-passwd.bin"' - "$creds" box bash -c 'vncpasswd -f < "$1/vnc-password" > "$1/vnc-passwd.bin" && chmod 600 "$1/vnc-passwd.bin"' - "$creds"
box Xvnc "$display" -geometry "${w}x${h}" -depth 24 \ exec {xlog}< <(box Xvnc "$display" -geometry "${w}x${h}" -depth 24 \
-interface "$addr" -rfbport "$vnc_port" \ -interface "$addr" -rfbport "$vnc_port" \
-SecurityTypes VncAuth -PasswordFile "$creds/vnc-passwd.bin" \ -SecurityTypes VncAuth -PasswordFile "$creds/vnc-passwd.bin" \
-AlwaysShared -desktop "Steam Frame (Frametop)" & -AlwaysShared -desktop "Steam Frame (Frametop)" 2>&1)
xvnc=$! xvnc=$!
sleep 2
# Keep an RDP connection open inside the VNC screen. Reconnect if it drops or the layout changes. # Wait up to $1 seconds, less if Xvnc logs something; its lines go on to this log.
nap() {
local line rc=0
IFS= read -rt "$1" -u "$xlog" line || rc=$?
if [ $rc -eq 0 ]; then
printf '%s\n' "$line"
while IFS= read -rt 0.1 -u "$xlog" line; do printf '%s\n' "$line"; done
elif [ $rc -le 128 ]; then
sleep 1 # Xvnc's output closed: it's exiting
fi
return 0
}
nap 2
# A connected VNC client: an established TCP connection to Xvnc's port. Any connection
# counts, authenticated or not; it's on the tailnet only.
clients() { [ -n "$(ss -Htn state established "( sport = :$vnc_port )" 2>/dev/null)" ]; }
# ft-screens drops screens you aren't looking at to a low frame rate, and krdp would
# stream that. "watch SECONDS" asks it for full rate on every screen for that long: sent
# when a client connects and renewed every few seconds while one stays, so it lapses by
# itself if this script dies. An older ft-screens just answers that it doesn't know it.
watch() {
if command -v socat >/dev/null; then
printf 'watch 15' | socat -u - ABSTRACT-SENDTO:ft_screens 2>/dev/null
else
python3 -c 'import socket; socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM).sendto(b"watch 15", "\0ft_screens")' 2>/dev/null
fi || true
}
# What remote-view depends on: KWin's saved outputs (positions, scales, primary) and
# Frametop's layout (screen sizes). It's read again only after one of these changes, and
# once a minute in case a change didn't touch them, and only while FreeRDP runs.
layout_files=("$HOME/.config/frametop/kwinoutputconfig.json" "$HOME/.config/frametop-layout.json")
stamp() { stat -c %y "${layout_files[@]}" 2>/dev/null || true; }
# Start FreeRDP inside the VNC screen, sized and shifted for $v.
# /cert:ignore is fine here: the connection never leaves this host. # /cert:ignore is fine here: the connection never leaves this host.
while kill -0 $xvnc 2>/dev/null; do start_rdp() {
read -r x y w h ww wh <<< "$v" read -r x y w h ww wh <<< "$v"
box env DISPLAY=$display bash -c ' box env DISPLAY=$display bash -c '
size=$1 x=$2 y=$3 ww=$4 wh=$5 creds=$6 rdp_port=$7 size=$1 x=$2 y=$3 ww=$4 wh=$5 creds=$6 rdp_port=$7
@@ -80,6 +125,7 @@ while kill -0 $xvnc 2>/dev/null; do
rdp=$! rdp=$!
# FreeRDP takes no negative position, so move its window once it is up. # FreeRDP takes no negative position, so move its window once it is up.
for _ in $(seq 60); do for _ in $(seq 60); do
kill -0 $rdp 2>/dev/null || break
win=$(xdotool search --class xfreerdp 2>/dev/null | tail -1) win=$(xdotool search --class xfreerdp 2>/dev/null | tail -1)
[ -n "$win" ] && break [ -n "$win" ] && break
sleep 0.5 sleep 0.5
@@ -88,14 +134,58 @@ while kill -0 $xvnc 2>/dev/null; do
wait $rdp wait $rdp
' vnc-rdp "${w}x$h" "$x" "$y" "$ww" "$wh" "$creds" "$rdp_port" || true & ' vnc-rdp "${w}x$h" "$x" "$y" "$ww" "$wh" "$creds" "$rdp_port" || true &
rdp=$! rdp=$!
while kill -0 $rdp 2>/dev/null; do }
sleep 5
now=$(view) || continue idle_sec=${VNC_IDLE_SEC:-45}
[ -n "$now" ] && [ "$now" != "$v" ] || continue rdp= # FreeRDP's job while it runs
seen=0 # when a client was last seen ($SECONDS)
watched=-99 # when "watch" was last sent
stamp_v= # stamp() when $v was read
recheck=0 # read the layout every 2 seconds until then
next_view=0 # next time to read it
while kill -0 $xvnc 2>/dev/null; do
if clients; then
if [ $((SECONDS - watched)) -ge 5 ]; then watch; watched=$SECONDS; fi
seen=$SECONDS
if [ -z "$rdp" ]; then
echo "VNC client connected, starting the RDP client"
now=$(view) && [ -n "$now" ] && v=$now
stamp_v=$(stamp) recheck=0 next_view=$((SECONDS + 60))
start_rdp
fi
elif [ "$seen" -ne 0 ]; then
watched=-99
if [ $((SECONDS - seen)) -ge "$idle_sec" ]; then
seen=0
if [ -n "$rdp" ]; then
echo "no VNC client for ${idle_sec}s, stopping the RDP client"
stop_rdp
wait "$rdp" 2>/dev/null || true
rdp=
fi
fi
fi
if [ -n "$rdp" ] && ! kill -0 "$rdp" 2>/dev/null; then
# It dropped, or the layout changed: reconnect next round if a client is still there.
wait "$rdp" 2>/dev/null || true
rdp=
nap 2
continue
fi
if [ -n "$rdp" ]; then
# A layout change shows up in KWin's outputs a few seconds after the files change.
s=$(stamp)
[ "$s" = "$stamp_v" ] || { stamp_v=$s; recheck=$((SECONDS + 10)) next_view=$SECONDS; }
if [ "$SECONDS" -ge "$next_view" ]; then
next_view=$((SECONDS + 60))
[ "$SECONDS" -ge "$recheck" ] || next_view=$((SECONDS + 2))
now=$(view) || now=
if [ -n "$now" ] && [ "$now" != "$v" ]; then
echo "layout changed: $v -> $now" echo "layout changed: $v -> $now"
v=$now v=$now
stop_rdp stop_rdp
done fi
wait $rdp 2>/dev/null || true fi
sleep 2 fi
if [ -n "$rdp" ] || [ "$seen" -ne 0 ]; then nap 1; else nap 5; fi
done done