# 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()` 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/--