Files

486 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mac in the headset
Frame Control can show any Mac window, or a whole Mac screen, as its own panel
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
only).
The confidence labels are the same as in [ssh.md](ssh.md).
## Why this design
First-party options come first, as the repo's rule asks, with the reason
each one was or wasn't chosen. The full list for every device is in
[streaming.md](streaming.md#first-party-options-and-why-they-do-or-dont-fit).
Checked 2026-09-28.
| Goal | First-party option | Chosen? | Why |
|---|---|---|---|
| One Mac screen in the headset | **Apple Screen Sharing** (VNC) → Remmina (Remmina 1.4.43 is already installed on this Frame) | Kept as the fallback (`panel-on-frame.sh mac-screen`) | It's the closest to first-party and needs nothing new. But VNC sends compressed tiles rather than video, so moving content is slow: noticeable lag even on a good 5 GHz link (**verified** 2026-09-27, see [streaming.md](streaming.md)), and the Mac's pointer isn't in the picture without a helper. It shows only whole screens |
| One Mac screen | **Steam Remote Play**, Mac as host (Valve) | For Mac games only; see [Steam's own streaming](#steams-own-streaming) | The Mac's and the Frame's Steam clients already find each other (**verified**). But Remote Play streams a game (the whole desktop only while the game is out of focus, untested from a Mac), never single windows, and a Mac can't host the Frame's VR streaming |
| One Mac screen | **AirPlay** (Apple) | No | Apple licenses AirPlay receivers only to TV and speaker makers, and nothing official runs on Linux. UxPlay is an unofficial receiver, and it mirrors a whole screen, not single windows |
| One Mac screen | **Sidecar / Mac Virtual Display** (Apple) | No | These work only with an iPad or Apple Vision Pro |
| **Each Mac window as its own panel** | None | – | No first-party way does this: Apple's per-app streaming is only for Vision Pro, and Valve's desktop streaming needs a Windows SteamVR host. So Frame Control does it itself |
| Mac keyboard and trackpad driving the headset | **Bluetooth HID** | No | macOS can't act as a Bluetooth keyboard or mouse. A real Bluetooth keyboard paired with the Frame still works |
| Mac keyboard and trackpad | **KDE Connect** (KDE) | No | The Frame has no `kdeconnectd` and it isn't on Flathub. Its Mac app has no keyboard or mouse sharing (**inferred**), and on Wayland it can only reach the desktop panel |
| Mac keyboard and trackpad | **xrdp** (Valve, Developer Mode) | No | It runs a separate Linux session that you view on the Mac. It isn't the headset's view, and it doesn't carry input the other way |
What that leaves is our own stream: nothing to install on the Mac or the
Frame, and whole screens or single windows. Here the Mac's own keyboard and
trackpad need no forwarding, because the windows are still on the Mac. The
laser is the only input that has to be sent back.
Other routes that were compared:
| Option | One screen | Each window | Speed | Verdict |
|---|---|---|---|---|
| Sunshine → Moonlight | ✓ | – | Good | Sunshine's macOS support is still experimental ([discussion #777](https://github.com/orgs/LizardByte/discussions/777)), and it captures whole screens only |
| Virtual Desktop, Immersed | – | – | – | No Frame client as of September 2026 |
| **Frame Control's own stream** | ✓ | ✓ | Hardware H.264, sending only changed frames | **Built** |
To type into VR surfaces other than these panels (SteamVR's dashboard,
games), the Frame supports a uinput keyboard and mouse without sudo
(verified 2026-09-27: `steamos` is in `input`, and `/dev/uinput` is
`root:input 660`). That's a separate feature, not part of this one.
## Steam's own streaming
The Frame is built around Steam streaming, so this was checked first
(2026-09-28). It fits Mac games, not Mac windows.
- **VR streaming from the Mac: no.** The Frame streams VR from a PC running
SteamVR ("Steam Link" with foveated streaming). SteamVR dropped macOS in
2020, and Valve lists PCs, laptops, Steam Deck and Steam Machine as
hosts, never a Mac (**documented**:
[UploadVR](https://www.uploadvr.com/steamvr-drops-mac-support/),
[Road to VR](https://roadtovr.com/steam-frame-game-certification-specs/)).
- **Flat Remote Play from the Mac: probably, for Steam games.**
- Steam on this Mac has streaming on, and the two Steam clients already
see each other. The Frame's `remote_connections.txt` shows it
connecting directly to "Alexs-MacBook-Pro-7" at 192.168.1.211:27036,
and the Mac's shows the Frame connecting over Wi-Fi and over the USB-C
link (**verified** in both clients' logs).
- Whether a stream then starts, and how a flat game looks in the headset
(reviews describe a theater screen), is **not tested yet**. The Frame's
Steam was crash-looping during this session (below).
- Mac-hosted Remote Play has a long-standing report of the stream
closing as the game loads
([Steam forum](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/),
**reported**).
- **The Mac desktop through Steam: untested; single windows: no.** Valve
says Remote Play shows the host's desktop when the game loses focus
([Steam Remote Play FAQ](https://help.steampowered.com/en/faqs/view/0689-74B8-92AC-10F2),
**documented**), so a whole Mac screen may be reachable by starting a
game, then switching away from it. Nobody has tried that from a Mac
host. It would still be one screen in one panel: Remote Play has
nothing like one panel per Mac window, so Frame Control's own stream
stays the way to see separate windows.
- **Steam has a "stream desktop" call, and it pairs with a Mac
(verified 2026-09-28).** In the Remote Play device list, the Frame's
Steam UI calls `SteamClient.RemotePlay.StartDesktopStream(<client id>)`
for a connected device. Called over CDP with the Mac's client ID, it made
the Mac's Steam show "Authorize Device" and ask for a 4-digit code shown
on the Frame. Once the code was entered, the Mac logged
`k_ERemoteDeviceAuthorizationSuccess`. No stream started in that attempt,
and a second attempt, now that the device is authorized, is the next
test. If it streams the Mac's desktop, that's a whole-screen option built
into Steam: one panel, Valve's encoder and transport. It still wouldn't
give each window its own panel.
- **What Steam's work did give us: the USB-C link.** Plugged into the Mac,
the Frame appears as a network port called "Steam Frame". Steam's Remote
Play discovery uses it, and so does Frame Control's stream now (see
"USB-C, when it's plugged in" below).
Next steps, once Steam on the Frame is healthy:
1. Call `StartDesktopStream` again now that the Frame is authorized, and
compare its latency and sharpness with Frame Control's stream of the same
screen.
2. Stream the one Mac game installed here (Fortune Mill) from the Frame's
library, over Wi-Fi and over USB-C.
3. Record whether it starts, how it's shown, and its latency. Steam's
streaming overlay shows this; our benchmark can't measure it.
4. While streaming, switch away from the game on the Mac, and see whether
the Mac's desktop appears in the headset, and whether its keyboard and
pointer work.
5. If it works, Frame Control's Games page could offer "Stream from the
Mac" for Mac-installed games.
## How it works
```
Mac Frame
ScreenCaptureKit (one window or display)
→ VideoToolbox H.264 (hardware, low-latency,
no B-frames)
→ frame-mac-view, 127.0.0.1 ──ssh -R──→ 127.0.0.1:479xx
→ Chromium app window per stream
(WebCodecs decode), on gamescope's
X display, tagged STEAM_GAME
→ its own SteamVR panel
← CGEvent (clicks, drags, wheel, keys) ←──── pointer, wheel and key events
```
- **The agent** is `mac/bin/frame-mac-view`, built from `mac/frame-mac-view`
(Swift, no dependencies; `build.sh`). Frame Control's server starts it on
first use and stops it on quit.
- It captures with ScreenCaptureKit, which sends frames only when something
changes, so idle windows cost nothing.
- It encodes in hardware with VideoToolbox's low-latency rate control (plain
real-time mode where that's unavailable).
- While anyone is watching, it keeps the Mac's display awake. A sleeping
display isn't drawn, so there would be nothing to capture.
- **The link** is an `ssh -R` tunnel on its own connection. It's encrypted and
works anywhere `ssh frame` works, Tailscale included, with no firewall
changes on the Mac. If the headset sleeps or the network drops, Frame
Control reopens the tunnel on the same port, and open viewers reconnect by
themselves.
- **USB-C, when it's plugged in.** Connected to the Mac by cable, the Frame
is also a USB network device: macOS lists a network port called "Steam
Frame", and the Frame's `usb0` answers in under 1 ms. Frame Control
checks for it each time it opens the tunnel and uses it when it's there,
with the Frame's usual SSH host key. Otherwise it uses the normal path.
`FRAME_MACVIEW_USB=0` turns this off. **Verified** 2026-09-28, in two
interleaved pairs of runs:
| | USB-C | Wi-Fi (Tailscale) |
|---|---|---|
| test: content p50 / p95 | 6.9–7.3 / 8.4–8.8 ms | 9.8–10.1 / 12.1–12.3 ms |
| test: click to drawn p50 | 16.6–16.9 ms | 26.4–27.8 ms |
| scroll: content p95 | 23.0–23.5 ms | 31.4–36.9 ms |
| scroll: late frames | 3.2–3.3% | 4.7–7.0% |
(`bench/results/2026-09-28-*-usb1.json`, `-usb2`, `-wifi1`, `-wifi2`.)
- **Access.**
- Frame Control's own key never leaves the Mac.
- Each viewer is opened with a **single-use ticket**. It's tied to one
window or display and expires after a minute. It's spent as soon as the
viewer confirms it has received its reconnect key. Until then, a retry
gets the same key, so a connection lost at that moment doesn't strand
the viewer. Stop revokes tickets that haven't been used yet.
- After that, the viewer holds a reconnect key for that one source, in
memory only. **Stop** revokes it.
- Remaining risk: a program running as `steamos` on the Frame could read a
ticket from Chromium's command line in the first second or so and use it
first. That gets it the one source being opened, not the Mac, and the
real viewer would then fail to connect. Android apps in Lepton run in
their own podman container, so they shouldn't see the Frame's process
list (inferred, not checked).
- **The viewer** is `ui/mac-view.html`, served by the agent. It opens on the
Frame as a Chromium app window, preferring Chromium XR (`~/chromium-xr`,
built with H.264) over Flathub Chromium.
- The page puts `[fcNNNNN]` in its title. The launcher finds the window by
that tag and sets `STEAM_GAME` to a stable id per source, which gives it
its own panel (see [panels.md](panels.md)). The same Mac window gets the
same panel id each time.
- It decodes with WebCodecs. If it falls behind, it skips to the next
keyframe instead of showing old frames late.
- It falls back to JPEG stills (**Compatible** quality) where H.264 isn't
available.
- **Flow control.** The agent never lets frames queue up anywhere on the
way. It skips capture frames *before* encoding, so no reference frame goes
missing, and it lowers the bitrate, then the frame rate, then the size, to
fit the link (see [Adapting to the network](#adapting-to-the-network)).
- **Input.**
- A click on a window's panel brings that Mac window to the front
(Accessibility API), then clicks at the same point. Double clicks, right
clicks, drags and the wheel work too.
- Keys typed into the panel are sent as Mac key codes. Any keys or buttons
still held down are released if the viewer loses focus or disconnects, or
when the stream stops. Characters the key
table doesn't know, such as those from other keyboard layouts, are typed
as text.
- The Mac's own keyboard and trackpad keep working as normal. Click a panel
with the laser, then type on the Mac.
## Permissions (Mac)
- **Screen Recording**, to see windows. Without it, the card asks for it.
- **Accessibility**, so input from the headset reaches the Mac. Without it the
stream still works, and the viewer says clicks won't go through.
Both are granted to Frame Control. After granting, press **Refresh**, which
restarts the helper so it picks them up. The app is ad-hoc signed, so macOS
may ask again after an update.
## Quality settings
| Setting | Long side | fps | Codec | Use |
|---|---|---|---|---|
| Sharp | 2560 | 60 | H.264, ~0.14 bits/pixel | Text-heavy windows on a strong link |
| Balanced (default) | 1920 | 60 | H.264, ~0.1 bits/pixel | Most things |
| Light | 1280 | 30 | H.264 | Weak Wi-Fi or Tailscale off the LAN |
| Compatible | 1280 | 20 | JPEG | A Frame browser without H.264 |
## Measuring
Every frame carries a sequence number, and the agent records its journey on
the Mac's clock (`Sources/Stats.swift`):
| Stage | From → to |
|---|---|
| capture | the Mac composited it (ScreenCaptureKit's display time) → the agent got it |
| queue, encode | → encoding started → the encoder finished |
| network | → the viewer received it |
| decode, draw | → WebCodecs decoded it → it was drawn on the page's canvas |
| present | → the page's next animation frame |
- **Clock sync.** The viewer syncs its clock to the Mac's the way NTP does:
it pings over the stream's own WebSocket and keeps the sample with the
shortest round trip. It then reports, in Mac time, when each frame arrived
(right away, so the agent can pace itself) and when it was decoded and
drawn (in batches every 250 ms).
- **Input.** The first frame captured after a click or key carries that
event's id. So input latency is the viewer's event → injected on the Mac →
the first frame after it → drawn in the headset.
- **Where to see it.**
- `GET /stats` (key required) returns every frame and input record.
- `/status` includes a two-second summary, which Frame Control's card
shows next to each live stream.
- In the headset, add `?stats=1` to the viewer or press
Ctrl+Alt+Shift+S for an overlay.
- **The benchmark.** `scripts/macview-bench.py` runs fixed scenarios on the
real Frame, from the Mac, with nobody wearing the headset:
- **test** is the moving test pattern.
- **scroll** is a Chrome page on its own display scrolling at 240 pt/s,
which gives about 9 Mbit/s of real 1920×1290 video.
- **type** types into a Chrome text box, first fast and then with pauses.
It writes `bench/results/<date>-<commit>-<label>.json`, compares two
results, and runs interleaved A/B tests between agent settings (`ab`).
Wi-Fi changes from minute to minute, so single runs at different times
aren't comparable. Throttled links come from a shaping relay on the Mac,
which needs no sudo (`--net 50@0,3@8,50@16` means 50 Mbit/s, then 3 from
8 s, then 50 from 16 s).
- **What's graded.** "Content" runs from when the Mac composited a frame
(or when ScreenCaptureKit delivered it, if that was earlier) to when it was
drawn in the viewer. The Frame's compositor adds its own delay after that.
That part is reported, but not graded: an unworn Frame throttles panels to
about 36 fps after a few seconds, and to 15 fps in standby, whatever they
draw. This was **verified** with a local canvas page that ran on the Frame
with no network involved (`bench/pages/present.html`). The compositor's
share needs a run with the headset worn.
Targets: content p50 ≤ 25 ms (p95 ≤ 40), click to photon p50 ≤ 50 ms
(p95 ≤ 70), 60 fps with ≤ 1% late frames, no stall over 100 ms, and adapting
to a new link rate within 1 s.
Baseline on 2026-09-28 (**verified**, home Wi-Fi, Tailscale, Balanced,
`bench/results/2026-09-28-a3c6e5c-dirty-baseline-fixed.json`; ms p50/p95):
| Scenario | Content | Input to drawn | fps drawn | Notes |
|---|---|---|---|---|
| test (1280×720) | 9.9 | 28.8 / 39.0 | 59 | encode 4.0, network 4.0, decode 1.3 |
| scroll (1920×1290) | 14.7 | – | 50.4 | encode 6.7, decode 6.4 (software), 9.4 Mbit/s, 16% late |
| type (1920×1290) | 16.7 | 51.2 / 73.2 | – | most of the input time is the Mac app reacting |
After this work, with the controller on (**verified**, same setup,
`bench/results/2026-09-28-9e4dcdd-final.json`; ms p50/p95). Content is
now measured from the earlier of display time and delivery, which adds
about 5 ms to scroll compared with the baseline's way of measuring:
| Scenario | Content | Input to drawn | fps drawn | Grades |
|---|---|---|---|---|
| test | 10.5 / 16.7 | 29.6 / 36.3 | 60 | all within target |
| scroll | 19.8 / 27.4 | – | 55.9 | fps, late frames (4.8%) and worst gap (222 ms) only "acceptable": Wi-Fi stalls (the Mac captured 57 fps in this run; earlier runs got 46–53 from virtual displays) |
| type | 15.0 / 21.4 | 43.0 / 60.5 | – | all within target |
Of the targets, click to photon is met without the Frame's compositor (the
headset has to be worn to measure its share), and so is content latency.
The frame-rate and no-stall targets aren't yet met while scrolling.
**Worn** (**verified** 2026-09-28 22:00, `vrcmd --stats` activity level 1,
home Wi-Fi over Tailscale, `bench/results/2026-09-28-39afb23-worn.json`;
ms p50/p95):
| Scenario | Content | Input to drawn | fps drawn | What happened |
|---|---|---|---|---|
| test | 10.8 / 15.3 | 25.7 / 34.4 | 58 | as unworn |
| scroll | 22.2 / 141 | – | 37.7 | Wi-Fi queued 56–364 ms and held frames for 220–270 ms; the controller went to 2.6 Mbit/s and 45 fps |
| type | 13.5 / 24.3 | 56.8 / 106 | – | slower replies than unworn (43 / 60) |
The Frame's CPU wasn't the limit: about 27% in total, and the viewer took
45% of one core. The link to a headset on someone's head is much rougher
than to one lying still. The adaptation keeps latency bounded there, but
the frame rate drops. The USB-C cable avoids Wi-Fi entirely. Frames
actually shown were still about 39 fps for the test pattern while worn, so
the unworn throttling isn't the whole story. The cause is **unknown**.
What was learned (all **verified**, unless marked):
- The biggest costs are encoding (4–7 ms), network (4–6 ms), and decoding
on the Frame. Chromium XR on the Frame decodes H.264 in **software**.
- Wi-Fi alone stalls for 240–580 ms now and then, over Tailscale and over
the LAN alike. Over the LAN (`--host 192.168.1.237`) latency was no
better, but the Frame used about 8% less CPU, because tailscaled runs in
userspace there.
- With no controller, a link that slows down queues without limit. In one
run, frames arrived 1.9 s late, and at worst 9.4 s late.
- Tried, and no help, so not kept: VideoToolbox options (require hardware,
no frame delay, prioritise speed, a hard data-rate cap), Chromium flags
(`--disable-gpu-vsync`, `--disable-frame-rate-limit`,
`--use-angle=vulkan`), and a 120 Hz virtual display.
- Inconclusive, so off by default: keeping the Frame's Wi-Fi awake during
typing (`FRAME_MAC_VIEW_WARM=40`, a tiny message every 40 ms for 5 s
after input). Over three interleaved runs each, input p50 went 44 → 47 ms
and p95 92 → 71 ms, and the ranges overlapped widely
(`…-ab-keepwarm.json`). In that run, and in the baseline's typing, the
harness typed spaces as "+" (a URL-encoding bug, since fixed), so they
went through the text path rather than as space keys.
- Pointer moves now go out on an 8 ms timer, not on the page's next
animation frame, which an unworn Frame slows to 15–36 Hz. This is
**inferred** to help dragging; the benchmark has no drag scenario yet.
- Kept: the encoder's timestamps never jump more than two frame intervals.
Before this, the first frame after a pause got a quarter of a second's
bit budget, and one P-frame reached 204 KB.
## Adapting to the network
`Sources/Controller.swift`, per stream, latency first:
- **The gate.** The viewer acknowledges every frame as it arrives. A new
frame is sent only while the oldest unacknowledged one is younger than
the path's usual round trip, plus one frame interval, plus room for this
link's normal jitter (1.5 times its recent spread, 25–80 ms). So frames
never queue in SSH, TCP or the Wi-Fi driver. While the link is stuck, the
newest picture waits and goes out as soon as it moves.
- **The bitrate.** The link counts as congested when, for two checks in a
row (100 ms apart), round trips grow by more than 40 ms while the stream
uses much of its budget, or the gate holds frames back, or a frame is
stuck for 100 ms. Then the bitrate drops to a bit under what actually got
through: at least a fifth off, and at most half. Once the link has been
clear for a second, it rises by 10% steps, never above the quality
setting's bitrate.
- **The tier.** When the bitrate stays low, and the stream is really
limited by the link rather than having little to send, it steps down:
60 → 45 → 30 fps, then 75%, then 50% of the pixels. It goes straight to
the tier the bitrate supports after half a second, and steps back up one
tier at a time after two seconds with room to spare.
- `FRAME_MAC_VIEW_ADAPT=0` turns it off, for comparison.
Measured on the real Frame, 2026-09-28 (**verified**; interleaved A/B, off
versus on, medians of the runs, ms):
| Link | Scenario | Content p95, off → on | fps drawn, off → on | Result file |
|---|---|---|---|---|
| Clean Wi-Fi (3 runs each) | scroll | 32.3 → 33.6 | 57 → 56.2 | `…-ab-adapt-clean2.json` |
| Clean Wi-Fi | test | 15 → 14.2 | 60 → 59.7 | same |
| 50 → 3 → 50 Mbit/s at 8 s and 16 s (2 runs each) | scroll | **4670 → 72** | 45 → 42 | `…-ab-adapt-step3.json` |
| 50 → 3 → 50 Mbit/s | test | 66 → 16 | 60 → 60 | same |
- **Clean link.** On a clean link it costs nothing measurable. An earlier
version with a fixed gate slack lost 9 fps to Wi-Fi jitter while
scrolling (47 → 38 fps), and that's why the slack now follows the link's
jitter.
- **Throttled link.** Without the controller, frames queued for up to 5.6 s
and never caught up while the link was slow (p95 1.8–5.6 s, second by
second). With it, in the two runs:
| | Run 1 | Run 2 |
|---|---|---|
| Worst second's p95 just after the drop | 219 ms | 428 ms |
| p95 back under 100 ms for 3 s in a row | after 1 s | after 4 s |
| Stepped down to 1440 px at 30 fps | 1.8 s after the drop | 3.5 s after |
| p95 per second after that, on the 3 Mbit/s link | 55–94 ms | 60–148 ms |
Sending one frame takes about 27 ms on that link by itself. After the
link recovered, the stream was back at full size and 60 fps in about
7.5 s. It steps up one tier every two seconds, on purpose, so it doesn't
bounce. The 1 s adaptation target was met in one run of two.
- **A hiccup on a small stream.** In one clean run, a Wi-Fi hiccup made an
earlier version halve the test pattern's bitrate five times and drop it
to half size. That cut couldn't help: the stream only sends
0.47 Mbit/s. Now the controller estimates what a stream wants (captures
per second × average frame size). While a stream wants about half its
budget or less, no cut takes it below twice what it wants, so it doesn't change
tier.
## Checked so far (2026-09-28)
- **Mac (checked by hand, macOS 26.5.2).**
- The agent builds, and lists windows and displays.
- In a browser, the test pattern decoded at about 60 fps (H.264) and 30 fps
(JPEG, about 22 Mbps).
- A click in the viewer arrived at the same point in the source.
- `pmset -g assertions` showed the display-awake assertion only while a
stream was being watched.
- **Automated** (`tests/test_macview.py`, on CI's macOS runner): the agent
builds (a build failure fails the job).
- `/ping` and the page are open; everything else needs the key.
- Tickets work once and only for their own source, and Stop revokes
reconnect keys.
- A WebSocket frame claiming 2^63 bytes closes that socket, and the agent
keeps running.
- A stream sends an SPS-led H.264 keyframe, and sends another when asked.
- Stop ends the stream on the Mac even if the viewer ignores it.
- The test doesn't decode video, time it, or check where clicks land.
- **Linux aarch64 (verified in a stand-in, not on the Frame).** In an Arch
Linux ARM container with sshd, Xvfb as `:0` and Chromium 153:
- The real `show` path worked in 1–2.3 s: tunnel, ticket, launcher,
window found and tagged `STEAM_GAME`.
- Frame Control's key didn't appear anywhere in the stand-in's process
list.
- After the tunnel was killed, it came back on the same port within 4 s,
and the viewer reconnected by itself.
- Chromium decoded the stream in software with the GPU off.
- Stop closed the window, and Chromium exited.
- This test found and fixed a bug: in a C locale, `xwininfo` can't print a
title with non-ASCII characters, so the launcher reads `_NET_WM_NAME`
with `xprop`.
- **On the Frame (verified 2026-09-28, SteamOS build 20260925.6191901,
from the Mac, nobody wearing the headset).** Frame Control's **Show** with
the test pattern:
- The panel was ready in 1.45 s. SteamVR logged `[Overlays] Created:
valve.steam.desktopgame.2001639889` (in `vrwebhelper_systemui.txt`).
- The window was tagged `STEAM_GAME`, and gamescope sized it to 1920×1080
although 1280×720 was asked for.
- Chromium XR decoded it live at about 60 fps: the frame counter advanced
62 in 1.04 s.
- Over home Wi-Fi, the frames in the viewer window had been drawn on the
Mac about 11–17 ms earlier, plus `xwd`'s own time. That's measured
against the two clocks, which were 37–39 ms apart (±4 ms, measured over
one SSH session). SteamVR's compositor and the display come on top.
- Clicks and keys injected with XTest into gamescope's Xwayland didn't
reach the page. That's inconclusive, not a failure: XTest on gamescope
isn't how real input arrives. The laser should arrive as a left mouse
button and the thumbstick as a wheel, because the window has an app id
(from gamescope's source; see [panels.md](panels.md)).
- **Not yet checked:**
- Clicking, dragging and scrolling with the laser while wearing the
headset.
- Real window capture and input on a Mac with both permissions granted.
- Keys from SteamVR's on-screen keyboard.
- Whether Flathub Chromium has H.264. Chromium XR is used when it's
installed, as it is on this Frame.
- Latency with real, busy windows at Sharp.
- A benchmark run while wearing the headset. Only then does the Frame show
panels at full rate, so only then can the compositor's share of the
latency, and the frame rate you actually see, be measured.
## Limits
- Only windows on the Mac's current desktop (Space) are listed, and
minimised windows can't be captured.
- A window's panel shows only that window. Its menus and sheets are separate
windows on the Mac, so open them from the Mac or use **Whole screen**.
- Keys go to whichever Mac window is in front. Clicking a panel brings its
window to the front first.
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
with the Frame.
## Switching panels and workspace limits
**Tools → Panel switcher** lists open SteamVR panels, including Mac viewers.
Use **Show** to request focus or **Open in headset** for Frame Control's own
switcher panel. It uses SteamVR/gamescope and Chromium, with no third-party
overlay app. [Device checks and limits](panels.md#frame-controls-panel-switcher)
include the difference between a panel surviving a scene launch and staying
visible over it.
Saved spatial layouts are blocked on this build: the public OpenVR transform
setter denies access to gamescope-owned panels. Reconnecting an existing viewer
is supported; restoring its room position after a reboot is not. We do not
save short-lived Mac window IDs or viewer access keys as if they were a durable
workspace. See [the feasibility evidence](panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route).