# 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) | No | macOS isn't a SteamVR host, and Mac-hosted Remote Play is reported broken ([Steam forum](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)). It streams games, not the desktop. **Not tested here**; one real try is still worth doing | | 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. ## 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. - **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/--