Compare commits

..
Author SHA1 Message Date
0x1f6 66160aed55 Gaze: detect the eye-server.mmap layout at runtime (SteamOS beta, +5 shift)
SteamOS 0.4.x beta (SteamVR 2.18.2) moved every field of
/dev/shm/eye-server.mmap from the timestamp on by 5 bytes; the counter at
0x38 kept its place. With the old constants ft-gaze read neighboring fields
as floats, and ft-gazed discarded every sample as broken JSON. Measured on
2026-10-04 (SteamOS 0.4.3 beta, SteamVR 2.18.2, ftdiag scan with an active
gaze session):

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

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

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

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

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

No files matched your search

+1 -13
View File
@@ -133,7 +133,7 @@ A few overlays need special handling:
Head follow is experimental and off by default. It works, but it's only lightly tested, and the feel is mostly a matter of its settings; polishing it is left open. With it on (`POINTER_FOLLOW=1`, or a mouse button mapped to Head follow on/off), the cursor rides on a reference direction, where you were facing when your head last settled, and keeps its offset from it. The mouse can put the cursor anywhere up to `POINTER_FOLLOW_REACH` (70 degrees) from the reference, a corner of your view included. While your head stays within `POINTER_LEASH_DEG` of the reference, nothing moves on its own. Once your head has been past the leash for `POINTER_LEASH_DELAY` (0.2 s, so a glance out and back doesn't count), the reference eases to where you're facing (time constant `POINTER_LEASH_RETURN`, 0.2 s), never falling further behind than the leash, and the cursor ends up back where it was in your view. Then it waits for the leash again. Two earlier versions didn't work out. Moving the reference only while your head pulled at the end of the leash left it up to the leash off after you turned back, and getting it centred again meant overshooting with your head. Easing it toward your facing all the time moved the cursor on every small head movement. A leash of 0 makes the reference your facing direction, so the cursor is locked to your view, and mouse movement shifts it within the view. Head roll is ignored, so tilting your head doesn't swing the cursor around. While the left button is held the cursor stays put in the room, so your head can't nudge a click or a drag. When you let go, it carries on from where it is instead of jumping.
Gaze mode is experimental and off by default (`POINTER_GAZE=1`, the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, or a mouse button or key combination mapped to Gaze pointer on/off). It's MAGIC pointing (Zhai, Morimoto and Ihde, 1999): the pointer goes where you look, and the mouse does the last bit. The gaze service (`gaze/ft-gazed`) sends the helper the corrected gaze at 90 Hz (from one eye while the tracker has lost the other), and while the gaze has the pointer, the cursor ray is that gaze from the eye. The pointer is aimed at the gaze each frame, not steered toward it, so nothing can pile up. An earlier try in the gaze probe steered the pointer with relative moves, and lost it when the pointer went idle or a controller had the laser. By default (`POINTER_GAZE_MOUSE_MOVE=held`, the Gaze page's Mouse movement switch) moving the mouse does nothing while the gaze has the pointer: it moves the pointer only while a button is held, as a correction. A bumped or drifting mouse can't pull the pointer off what you're looking at, and every mouse move is a correction, so the lessons aren't polluted by mouse moves to somewhere else (they used to be kept out by an 8 degree limit, which also dropped real corrections when the tracker was further off). With the gaze stale for a second, in a game, or with the headset off, the mouse moves the pointer as usual; with `free`, moving the mouse takes the pointer from the gaze. A left press while the gaze has the pointer isn't sent at once: the pointer stops where the gaze put it, you drag it onto what you meant with the button still down (panels only see it hover), and the release clicks there. Clicking at once clicked wherever the gaze was, often the wrong thing, before you could correct it. The drag is the correction. Snapping the pointer onto buttons and links is deferred: the session now starts an AT-SPI registry, but apps still need to expose useful accessibility trees (and may need restarting), and it makes Chromium and Electron apps use more CPU. A press held still for `POINTER_GAZE_HOLD` (0.5 s) becomes a real press, so drags still work: hold, then move. The right button works the same way, with the right click on the release, and pressing it while the left press is held back starts a drag where the pointer is, like Meta+J then Meta+K. That drag lasts while either button (or key) is held, so a second right press, or a second Meta+K, is free to pan and tilt the panel being dragged; with the keyboard, the head turns it. Outside games the pointer then stays: the mouse going idle doesn't release it. A moving controller still releases it, as without gaze. Gaze mode is a mouse and keyboard feature: Steam reads the Frame controllers itself, outside SteamVR's bindings, so controller clicks at the gaze kept knocking SteamVR out of laser mode (see `docs/gaze-controllers.md`). Keyboard clicks (Meta+J, Meta+K) hold the dot still in your view while the keys are down, so the head, not the mouse, does the last bit; a quick tap clicks where the dot was at the press, since the head moves as you hit the keys. The relay hides Meta from the desktop as soon as such a combination fires, because KWin takes Meta with a mouse button as a window move or resize, which swallowed the clicks. The dot shows all the time by default. With `POINTER_GAZE_DOT=moving` it shows only while the mouse moves it (`POINTER_GAZE_SHOW`), while a press is held, and as a pulse for each click; otherwise it's transparent, so the laser still lands on it. Looking more than `POINTER_GAZE_RETAKE` (5 degrees) away from it, with the mouse still, gives it back, so small eye movements around the pointer don't pull it off what you're doing. A mouse nudge before a click whose correction is within `POINTER_GAZE_NUDGE_MAX` (55 degrees, half of what the headset shows across) is sent to the gaze service as a lesson: you were looking at where you clicked when the mouse took over, so the nudge is the eye tracker's error there. Using it is what calibrates it. A one-dot check in a panel fixed to the headset tops that up when the headset goes on, when our tracker thinks it moved, and when a correction is past that limit (the tracker is far off, so a click there isn't trusted as a lesson), and the full calibration and the headset fit check run in the same panel, so everything a user does to calibrate happens in one place in the headset; the gaze probe, a fullscreen GTK app, is the development tool. The limit was 8 degrees, which dropped every correction while our tracker was 12 off. Its dots sit at known directions from the headset, so the panel needs no screen geometry. The quick check's dot takes the gaze once it has held still, so what the tracker says doesn't have to be close for the capture to work. The full calibration's and the five-dot check's dots wait for a click while you look at the dot (a left click or Meta+J), because a steady gaze isn't always on the dot, and take the gaze held still up to the click; a rightLine truncated
Gaze mode is experimental and off by default (`POINTER_GAZE=1`, the Gaze page of Frametop Input Settings, `gaze/ft-gazectl on`, or a mouse button or key combination mapped to Gaze pointer on/off). It's MAGIC pointing (Zhai, Morimoto and Ihde, 1999): the pointer goes where you look, and the mouse does the last bit. The gaze service (`gaze/ft-gazed`) sends the helper the corrected gaze at 90 Hz (from one eye while the tracker has lost the other), and while the gaze has the pointer, the cursor ray is that gaze from the eye. The pointer is aimed at the gaze each frame, not steered toward it, so nothing can pile up. An earlier try in the gaze probe steered the pointer with relative moves, and lost it when the pointer went idle or a controller had the laser. By default (`POINTER_GAZE_MOUSE_MOVE=held`, the Gaze page's Mouse movement switch) moving the mouse does nothing while the gaze has the pointer: it moves the pointer only while a button is held, as a correction. A bumped or drifting mouse can't pull the pointer off what you're looking at, and every mouse move is a correction, so the lessons aren't polluted by mouse moves to somewhere else (they used to be kept out by an 8 degree limit, which also dropped real corrections when the tracker was further off). With the gaze stale for a second, in a game, or with the headset off, the mouse moves the pointer as usual; with `free`, moving the mouse takes the pointer from the gaze. A left press while the gaze has the pointer isn't sent at once: the pointer stops where the gaze put it, you drag it onto what you meant with the button still down (panels only see it hover), and the release clicks there. Clicking at once clicked wherever the gaze was, often the wrong thing, before you could correct it. The drag is the correction. Snapping the pointer onto buttons and links is deferred: it needs accessibility (AT-SPI) on in the Frametop session, where it's off (no registry runs), plus app restarts, and it makes Chromium and Electron apps use more CPU. A press held still for `POINTER_GAZE_HOLD` (0.5 s) becomes a real press, so drags still work: hold, then move. The right button works the same way, with the right click on the release, and pressing it while the left press is held back starts a drag where the pointer is, like Meta+J then Meta+K. That drag lasts while either button (or key) is held, so a second right press, or a second Meta+K, is free to pan and tilt the panel being dragged; with the keyboard, the head turns it. Outside games the pointer then stays: the mouse going idle doesn't release it. A moving controller still releases it, as without gaze. Gaze mode is a mouse and keyboard feature: Steam reads the Frame controllers itself, outside SteamVR's bindings, so controller clicks at the gaze kept knocking SteamVR out of laser mode (see `docs/gaze-controllers.md`). Keyboard clicks (Meta+J, Meta+K) hold the dot still in your view while the keys are down, so the head, not the mouse, does the last bit; a quick tap clicks where the dot was at the press, since the head moves as you hit the keys. The relay hides Meta from the desktop as soon as such a combination fires, because KWin takes Meta with a mouse button as a window move or resize, which swallowed the clicks. The dot shows all the time by default. With `POINTER_GAZE_DOT=moving` it shows only while the mouse moves it (`POINTER_GAZE_SHOW`), while a press is held, and as a pulse for each click; otherwise it's transparent, so the laser still lands on it. Looking more than `POINTER_GAZE_RETAKE` (5 degrees) away from it, with the mouse still, gives it back, so small eye movements around the pointer don't pull it off what you're doing. A mouse nudge before a click whose correction is within `POINTER_GAZE_NUDGE_MAX` (55 degrees, half of what the headset shows across) is sent to the gaze service as a lesson: you were looking at where you clicked when the mouse took over, so the nudge is the eye tracker's error there. Using it is what calibrates it. A one-dot check in a panel fixed to the headset tops that up when the headset goes on, when our tracker thinks it moved, and when a correction is past that limit (the tracker is far off, so a click there isn't trusted as a lesson), and the full calibration and the headset fit check run in the same panel, so everything a user does to calibrate happens in one place in the headset; the gaze probe, a fullscreen GTK app, is the development tool. The limit was 8 degrees, which dropped every correction while our tracker was 12 off. Its dots sit at known directions from the headset, so the panel needs no screen geometry. The quick check's dot takes the gaze once it has held still, so what the tracker says doesn't have to be close for the capture to work. The full calibration's and the five-dot check's dots wait for a click while you look at the dot (a left click or Meta+J), because a steady gaze isn't always on the dot, and take the gaze held still up to the click; a right click or MetLine truncated
Replacing a loaded driver's files, as re-running the installer used to do, leaves SteamVR honoring the virtual controller's hand role but not its laser claim: the dashboard pointer stays unassigned until SteamVR restarts. The driver installer now leaves an unchanged driver in place.
@@ -173,18 +173,6 @@ The session is modeled on SteamOS's `steamos-nested-desktop` and runs beside it.
The VR launcher starts the session from the Steam client, and the client's environment came along: `LD_LIBRARY_PATH` pointing at Steam's own runtime, whose `libavcodec` has no H.264 decoder, so VLC in the desktop couldn't play most videos, plus the client's overlay and launch settings. The session script drops the client's variables before it starts anything. SteamOS's global Mesa settings (`/usr/share/deckard/mesavars.sh`) stay, and the gamescope session's Vulkan layer (`ENABLE_GAMESCOPE_WSI`) is only kept for the gamescope backend.
### Nested accessibility
The session drops an inherited `AT_SPI_BUS_ADDRESS`, so apps cannot accidentally use the host desktop's registry. It autostarts `session/ft-atspi` in Plasma phase 2, after KWin has set the nested display environment. The helper gets the live accessibility address from `org.a11y.Bus` on the private session bus, preserves any existing registry owner, updates the accessibility bus's activation environment, and tries `StartServiceByName` first.
On SteamOS 0.3.0 with at-spi2-core 2.52.0, the native launcher can choose dbus-broker because its process belongs to a systemd user unit. Registry activation then fails: this desktop's private session bus does not have a systemd activation manager. In that case the helper starts only `at-spi2-registryd` on the already-existing accessibility bus. The registry refuses duplicate ownership. Unlike native activation's `--use-gnome-session`, the fallback does not try to register with GNOME's session manager; that flag did not explain the observed native activation failure.
The fallback registry does not exit merely when its bus disconnects in the isolated SteamOS test. Its small watcher checks both private buses every 5 seconds, and terminates and reaps only the child it started when either bus disappears or the watcher is stopped. Each check runs `gdbus` twice; once a second, that cost about 1% of a core. `keep-apps.sh` keeps the watcher in the desktop unit when `desktops.sh start` runs the desktop as `frametop-desktop`. Started from the VR launcher, the desktop runs in steam.service, which doesn't stop with it, so there the watcher is the only thing that stops the registry. There is no second accessibility bus, global systemd environment update, process-name kill, or host registry replacement. Missing accessibility files or bus errors are nonfatal; the desktop still starts. Toolkit-specific accessibility opt-ins and pointer snapping are separate work.
Run the isolated checks on the host with `/usr/bin/python3 session/test/test_accessibility.py`. They use private D-Bus buses, Xvfb and a GTK3 app, never the production display or input. Native activation uses a small `org.a11y.Bus` test provider pointing to a real private dbus-daemon with the installed registry service; the SteamOS fallback uses the installed bus launcher and broker. The tests check real app-tree discovery, existing owners, concurrent starts, session stop/restart, and teardown. They require test-only PyGObject (Gio and GTK3), Xvfb, and at-spi2-core; the runtime helper uses Python's standard library and the existing host `gdbus`. Actual Plasma autostart and VR desktop restart still require an approved hardware test.
### Other session behavior
Steam, not systemd, suspends the Frame: after `system_idle_suspend_ac_sec` (an hour by default) without input on AC power, it logs `Switching to power state: k_ESystemPowerState_Sleep` and suspends, even while charging. It's a Steam setting (Settings → Power → When Plugged In and Idle → Sleep after), which the Stay awake while plugged in switch in Frametop Display Settings sets to Never. SteamVR's standby, which turns the displays off when the headset comes off, is separate; see below.
Flatpak apps need `XDG_DATA_DIRS` to include Flatpak's exports, or Plasma opens Discover instead of launching them, so the session sources `/etc/profile.d/flatpak.sh`.
-2
View File
@@ -18,8 +18,6 @@ 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.
The nested session also starts an AT-SPI accessibility registry through `session/ft-atspi` in Plasma's autostart. It discovers the bus from this session, ignores an inherited host accessibility address, and leaves an existing registry alone. Accessibility errors do not stop the desktop. This supplies the registry infrastructure for apps that expose AT-SPI trees; it does not enable gaze snapping or force Chromium/Electron accessibility. After an approved desktop restart, an AT-SPI-aware app should be visible on the nested bus. See [design.md](design.md#nested-accessibility) for native activation, fallback lifecycle, and the isolated test command.
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:
```
+95 -11
View File
@@ -98,6 +98,10 @@ double NowRaw() {
}
// --- eye-server.mmap (packed, unaligned: read with memcpy) ---
// The SteamOS 0.4.x beta (SteamVR 2.18.2) moved every field from the timestamp on by 5
// bytes (measured 2026-10-04 with the ftdiag scan: timestamp 0x157 -> 0x15c, the vectors
// moved with it; the counter at 0x38 kept its place). Which layout is live is detected at
// runtime (EyeFile::Detect), so one binary serves both generations.
constexpr size_t kCounter = 0x38; // u32, one per sample
constexpr size_t kTime = 0x157; // f64, CLOCK_MONOTONIC_RAW seconds
constexpr size_t kLeft1 = 0x15f, kRight1 = 0x16b; // set 1: unit vectors, head space
@@ -110,11 +114,18 @@ constexpr size_t kVar1 = 0x177, kVar2 = 0x1b3;
// The measurements the filter is fed: left x, y, right x, y, then the variance of each (left
// x, y, right x, y). An eye's pair stops changing while the tracker can't see it.
constexpr size_t kMeas = 0x1d3;
constexpr size_t kNeed = 0x1f3;
constexpr size_t kNeed = 0x1f3 + 5; // enough for either layout
struct EyeFile {
// Everything from the timestamp on is read at base + shift: 0 on Stable, 5 on the
// 0.4.x beta (see the constants above). known only once a ticking timestamp was found
// for the current shift; before that, reading would yield garbage that still passes
// the seqlock check below.
size_t shift = 0;
bool known = false;
const uint8_t *p = nullptr;
size_t size = 0;
size_t At(size_t base) const { return base + shift; }
bool Open() {
const int fd = open("/dev/shm/eye-server.mmap", O_RDONLY | O_CLOEXEC);
if (fd < 0) return false;
@@ -140,6 +151,41 @@ struct EyeFile {
std::memcpy(f, p + off, sizeof f);
return {f[0], f[1], f[2]};
}
// A timestamp that is neither near the monotonic-raw clock nor advancing is not the
// live timestamp field.
bool TimePlausible(double t, double now) const {
return t > now - 2.0 && t <= now + 2.0;
}
// The eye directions are unit vectors, so their length is a second, independent check
// next to the timestamp: a mere coincidence in one field does not confirm a layout.
static bool UnitVec(Vec3 v) {
const float n = v.x * v.x + v.y * v.y + v.z * v.z;
return n > 0.81f && n < 1.21f; // |v| within 0.9 .. 1.1
}
// Try both layouts and keep the one whose timestamp actually ticks and whose eye
// directions are plausible. Safe to retry while the eye server is silent (headset off,
// no samples for a while): it only succeeds once data flows. Cheap: two reads ~60 ms
// apart per candidate. If neither candidate fits, known stays false and callers must
// treat eye tracking as unavailable (never emit samples from an unknown layout).
bool Detect(double now) {
for (const size_t cand : {size_t(0), size_t(5)}) {
if (size < kTime + cand + 8 || size < kFix1 + cand + 12) continue;
const double t0 = Get<double>(kTime + cand);
if (!TimePlausible(t0, now)) continue;
if (!UnitVec(V(kLeft1 + cand)) || !UnitVec(V(kRight1 + cand))) continue;
usleep(60000);
const double t1 = Get<double>(kTime + cand);
const double now2 = NowRaw();
if (t1 > t0 && TimePlausible(t1, now2)) {
shift = cand;
known = true;
return true;
}
}
shift = 0;
known = false;
return false;
}
};
struct EyeSample {
@@ -151,20 +197,22 @@ struct EyeSample {
};
// A consistent copy: the writer has no seqlock we can use, so read until the counter and
// timestamp are the same before and after.
// timestamp are the same before and after. Only call this once the layout is known
// (EyeFile::known): on an unknown layout these reads still pass the consistency check,
// but yield garbage.
bool ReadSample(const EyeFile &f, EyeSample &s) {
for (int attempt = 0; attempt < 4; ++attempt) {
const uint32_t n0 = f.Get<uint32_t>(kCounter);
const double t0 = f.Get<double>(kTime);
const double t0 = f.Get<double>(f.At(kTime));
std::atomic_thread_fence(std::memory_order_acquire);
s.left1 = f.V(kLeft1), s.right1 = f.V(kRight1), s.fix1 = f.V(kFix1);
s.left2 = f.V(kLeft2), s.right2 = f.V(kRight2);
std::memcpy(s.open, f.p + kOpen, sizeof s.open);
std::memcpy(s.var1, f.p + kVar1, sizeof s.var1);
std::memcpy(s.var2, f.p + kVar2, sizeof s.var2);
std::memcpy(s.meas, f.p + kMeas, sizeof s.meas);
s.left1 = f.V(f.At(kLeft1)), s.right1 = f.V(f.At(kRight1)), s.fix1 = f.V(f.At(kFix1));
s.left2 = f.V(f.At(kLeft2)), s.right2 = f.V(f.At(kRight2));
std::memcpy(s.open, f.p + f.At(kOpen), sizeof s.open);
std::memcpy(s.var1, f.p + f.At(kVar1), sizeof s.var1);
std::memcpy(s.var2, f.p + f.At(kVar2), sizeof s.var2);
std::memcpy(s.meas, f.p + f.At(kMeas), sizeof s.meas);
std::atomic_thread_fence(std::memory_order_acquire);
if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(kTime) == t0) {
if (f.Get<uint32_t>(kCounter) == n0 && f.Get<double>(f.At(kTime)) == t0) {
s.n = n0, s.t = t0;
return true;
}
@@ -527,6 +575,13 @@ int main(int argc, char **argv) {
EyeFile eyes;
const bool haveMmap = eyes.Open();
std::fprintf(stderr, "ft-gaze: eye-server.mmap %s\n", haveMmap ? "open" : "not available");
// The mmap layout (the shift from the timestamp field on, see above) is detected at
// startup, and retried whenever fresh eye data arrives without a known layout.
// Until then, eye tracking counts as unavailable.
if (haveMmap) eyes.Detect(NowRaw());
if (haveMmap && eyes.known)
std::fprintf(stderr, "ft-gaze: eye-server.mmap layout: %s\n",
eyes.shift ? "beta (+5)" : "stable");
OwnFile ownFile;
Screens screens;
@@ -544,6 +599,12 @@ int main(int argc, char **argv) {
// over the dashboard. Games are told apart the way ft-screens does it, by the scene app.
bool inGame = false;
double nextGameCheck = 0;
// Layout detection runs only while the eye server is actually writing: the counter
// (0x38, unshifted in both layouts) ticks once per sample, so a silent server (headset
// off, gaze idle) causes neither retries nor journal noise. The "not recognized" line
// is rate-limited so an unrecognized but writing server doesn't flood the journal.
double nextLayoutCheck = 0, nextLayoutLog = 0, lastNewSample = 0;
uint32_t lastCounterSeen = 0;
while (true) {
const double now = NowRaw();
@@ -562,7 +623,30 @@ int main(int argc, char **argv) {
// One line per new eye sample, or at 90 Hz without the mmap.
EyeSample s;
bool fresh = false;
if (haveMmap && ReadSample(eyes, s) && s.n != lastN) fresh = true, lastN = s.n;
// Retry the layout when the eye server writes but we have no known layout, or
// when samples that used to flow have stopped: the layout may change under us
// (a SteamVR restart replaces the mmap), and detection only needs the writer
// alive, never our samples.
const bool writing = eyes.Get<uint32_t>(kCounter) != lastCounterSeen;
if (writing) lastCounterSeen = eyes.Get<uint32_t>(kCounter);
if (haveMmap && writing && (!eyes.known || now - lastNewSample > 2.0) &&
now >= nextLayoutCheck) {
nextLayoutCheck = now + 1.0;
const bool was = eyes.known;
if (eyes.Detect(now)) {
lastN = 0; // let the next sample count as new even if n repeated
if (!was)
std::fprintf(stderr, "ft-gaze: eye-server.mmap layout: %s\n",
eyes.shift ? "beta (+5)" : "stable");
} else if (now >= nextLayoutLog) {
nextLayoutLog = now + 60.0;
std::fprintf(stderr,
"ft-gaze: eye-server.mmap has eye data, but its layout is not recognized — eye tracking unavailable\n");
}
}
if (haveMmap && eyes.known && ReadSample(eyes, s) && s.n != lastN) {
fresh = true, lastN = s.n, lastNewSample = now;
}
if (!haveMmap && now - lastEmit >= 1.0 / 90) fresh = true, s.t = now;
if (fresh && hp.bPoseIsValid) {
-10
View File
@@ -49,8 +49,6 @@ PACKAGES = {
"after a desktop restart; floating a window; no blur behind the taskbar's menus",
"plasma-workspace": "the taskbar and panels after a desktop restart; no DiscoverNotifier or "
"ibus-daemon inside the desktop",
"at-spi2-core": "an AT-SPI-aware app appears on the nested desktop's accessibility bus; "
"the registry stops and comes back after a desktop restart",
"gamescope": "the headset's volume buttons with nothing focused; typing goes where you last clicked",
"bluez": "a Bluetooth mouse reconnecting after it sleeps",
}
@@ -164,14 +162,6 @@ def check_host():
("/usr/bin/kwin_wayland_wrapper", "FAIL", "the desktop can't start KWin"),
("/usr/bin/startplasma-wayland", "FAIL", "the desktop can't start Plasma"),
("/usr/bin/dbus-run-session", "FAIL", "the desktop can't start its session bus"),
("/usr/bin/python3", "warn", "nested accessibility can't run its startup helper"),
("/usr/bin/gdbus", "warn", "nested accessibility can't discover or check its bus"),
("/usr/lib/at-spi-bus-launcher", "warn", "nested accessibility can't start its bus"),
("/usr/lib/at-spi2-registryd", "warn", "nested accessibility has no fallback registry"),
("/usr/share/dbus-1/services/org.a11y.Bus.service", "warn",
"nested accessibility can't activate its bus"),
("/usr/share/dbus-1/accessibility-services/org.a11y.atspi.Registry.service", "warn",
"nested accessibility can't activate its registry natively"),
(f"{STEAMVR_BIN}/vrcmd", "FAIL", "the 3D mouse can't find panels"),
(f"{STEAMVR_BIN}/vrpathreg", "warn", "the pointer driver can't be installed or removed"),
("/usr/share/deckard/mesavars.sh", "warn", "the desktop starts without SteamOS's Mesa settings"),
+1 -16
View File
@@ -29,7 +29,7 @@ for var in $(compgen -e); do
case $var in
LD_LIBRARY_PATH | LD_PRELOAD | STEAM_* | Steam* | SRT_* | PRESSURE_VESSEL_* | MANGOHUD_* | \
ENABLE_VK_LAYER_VALVE_steam_overlay_* | STEAMVIDEOTOKEN | QT_IM_MODULE | GTK_IM_MODULE | \
XMODIFIERS | AT_SPI_BUS_ADDRESS) unset "$var" ;;
XMODIFIERS) unset "$var" ;;
esac
done
@@ -190,21 +190,6 @@ if [ "$remote" = 1 ]; then
"$here/remote-ctl.sh" start
fi
# AT-SPI: start the registry inside Plasma's autostart, after KWin has published the
# nested displays. Starting it before startplasma could bind to the host's X display.
# This config belongs only to Frametop; the host desktop's autostart is unchanged.
autostart=$XDG_CONFIG_HOME/autostart/frametop-atspi.desktop
mkdir -p "$(dirname "$autostart")"
cat > "$autostart" <<EOF
[Desktop Entry]
Type=Application
Name=Frametop accessibility
Exec="$here/ft-atspi"
X-KDE-autostart-phase=2
OnlyShowIn=KDE;
NoDisplay=true
EOF
# ft-floatd (floating windows) runs inside the Plasma session, on its D-Bus: started from
# the session's autostart, which only this desktop reads (XDG_CONFIG_HOME above).
autostart=$XDG_CONFIG_HOME/autostart/frametop-floatd.desktop
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/python3
"""Start AT-SPI on this nested desktop's live bus, after KWin has set its displays."""
import ast
import os
import signal
import subprocess
import sys
import time
DBUS = "org.freedesktop.DBus"
DBUS_PATH = "/org/freedesktop/DBus"
REGISTRY = "org.a11y.atspi.Registry"
def call(address, destination, path, method, *args):
return subprocess.run(
["gdbus", "call", "--address", address, "--dest", destination,
"--object-path", path, "--method", method, "--timeout", "5", *args],
check=True, capture_output=True, text=True, timeout=6).stdout.strip()
def stop(child):
if child.poll() is None:
child.terminate()
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
child.kill()
child.wait()
def run_registry(address, session):
# SteamOS's registryd stays alive after a bus disconnect. Keep only our own
# child tied to both private buses; no host PID lookup or broad process kill.
registry = subprocess.Popen(["/usr/lib/at-spi2-registryd"])
try:
while registry.poll() is None:
try:
call(session, DBUS, DBUS_PATH, DBUS + ".GetId")
call(address, DBUS, DBUS_PATH, DBUS + ".GetId")
except subprocess.SubprocessError:
break # Normal session shutdown or loss of its accessibility bus.
# Each check starts two gdbus processes, and SteamVR needs the CPU.
time.sleep(5)
finally:
stop(registry)
def start():
session = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
runtime = os.environ.get("XDG_RUNTIME_DIR", "")
if not session or os.path.basename(runtime) != "frametop":
return # Do not autolaunch a bus or touch the host desktop.
os.environ.pop("AT_SPI_BUS_ADDRESS", None)
reply = call(session, "org.a11y.Bus", "/org/a11y/bus", "org.a11y.Bus.GetAddress")
address, = ast.literal_eval(reply)
if not isinstance(address, str) or not address.startswith("unix:"):
raise ValueError("org.a11y.Bus returned no local accessibility bus")
if call(address, DBUS, DBUS_PATH, DBUS + ".NameHasOwner", REGISTRY) == "(true,)":
return
# Activation otherwise inherits the accessibility daemon's old environment (and
# DBUS_SESSION_BUS_ADDRESS is the accessibility bus itself). Pin the live bus.
env = {key: os.environ.get(key, "") for key in (
"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR",
"XDG_CONFIG_HOME", "DBUS_SESSION_BUS_ADDRESS")}
env["AT_SPI_BUS_ADDRESS"] = address
call(address, DBUS, DBUS_PATH, DBUS + ".UpdateActivationEnvironment", repr(env))
try:
call(address, DBUS, DBUS_PATH, DBUS + ".StartServiceByName", REGISTRY, "0")
except subprocess.CalledProcessError:
# SteamOS's launcher can select dbus-broker because we are in a user unit,
# but the private session bus has no systemd activation manager. Reuse its
# bus rather than starting another launcher/bus. Never replace an owner.
if call(address, DBUS, DBUS_PATH, DBUS + ".NameHasOwner", REGISTRY) == "(true,)":
return
os.environ["AT_SPI_BUS_ADDRESS"] = address
# registryd itself refuses to queue behind an existing registry, including
# races with native activation. The watcher cleans up only our own child.
run_registry(address, session)
if __name__ == "__main__":
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(0))
try:
start()
except (OSError, ValueError, SyntaxError, subprocess.SubprocessError) as error:
print(f"frametop: accessibility unavailable: {error}", file=sys.stderr)
+1 -1
View File
@@ -10,7 +10,7 @@ cg=$(systemctl --user show -p ControlGroup --value "$unit" 2>/dev/null)
# The desktop's own processes stay in the unit and stop with it: the session, KWin,
# Plasma, and the session services it started (portals, input methods, kded, wallet...).
own='^(frametop-sessi|dbus-|startplasma|plasma|kwin|Xwayland|ksmserver|krdpserver|Xvnc|xfreerdp|ft-layout|ft-atspi|'
own='^(frametop-sessi|dbus-|startplasma|plasma|kwin|Xwayland|ksmserver|krdpserver|Xvnc|xfreerdp|ft-layout|'
own+='kded|kactivitymanage|kaccess|kglobalaccel|kscreen|kwalletd|ksecretd|polkit-kde|org_kde_|baloo|'
own+='xembedsniproxy|gmenudbusmenu|DiscoverNotifie|kimpanel|ibus|xdg-|at-spi|dconf-service|fusermount|agent)'
keep=()
File diff suppressed because it is too large. Load diff