Record headset results for sideloading and devkit pairing

Tested on a Frame (BUILD_ID 20260922.6101926):

- Devkit pairing: the service runs and answers properties.json, but /register
  refuses with "please put the Steam client in pairing mode" unless Steam is on
  Settings > Developer > Pair new host. Both setup paths now say so and keep
  asking for 2 minutes before falling back to the password.
- Sideloading: registering, launching, quoted start paths and Remove work; a
  Windows exe runs under Proton 11 through FEX. An aarch64 build starts but
  outside the runtime container, and an x86-64 Linux build doesn't start
  because the x86-64 Steam Linux Runtime 4.0 isn't installed. The inspect note
  for x86-64 Linux builds now says so.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 22:31:05 +10:00
1 parent 84ac687399
commit 1580dae42e
8 files changed
+100 -30

No files matched your search

+5 -4
View File
@@ -149,10 +149,11 @@ computer.
password once in a terminal window. If it can't find the Frame, type the password once in a terminal window. If it can't find the Frame, type the
IP address from the Frame's Quick Settings. IP address from the Frame's Quick Settings.
Before asking for the password it tries Valve's SteamOS devkit pairing: if Before asking for the password it tries Valve's SteamOS devkit pairing: in
the headset shows a pairing request, approve it and no password is needed. the headset, open Steam Settings → Developer → **Pair new host** and approve
(**Inferred from Valve's source** ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service)), the request, and no password is needed. (The service and the pairing-mode
not yet verified on a Frame; see [SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).) step are verified on a Frame; the approval itself isn't yet. See
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
3. That's it. The app now reaches the headset whenever it's awake and on the 3. That's it. The app now reaches the headset whenever it's awake and on the
same network. For anywhere else, see [Tailscale](docs/tailscale.md). same network. For anywhere else, see [Tailscale](docs/tailscale.md).
+27 -9
View File
@@ -48,8 +48,8 @@ The program's header decides, not its file name:
|---|---|---|---| |---|---|---|---|
| Windows `.exe`, x86-64 (PE machine `0x8664`) | `proton-experimental` | 1 | Inferred: ARM64 Proton runs x86-64 code through FEX | | Windows `.exe`, x86-64 (PE machine `0x8664`) | `proton-experimental` | 1 | Inferred: ARM64 Proton runs x86-64 code through FEX |
| Windows `.exe`, 32-bit x86 (`0x14c`) or ARM64 (`0xaa64`) | `proton-experimental` | 1 | Inferred | | Windows `.exe`, 32-bit x86 (`0x14c`) or ARM64 (`0xaa64`) | `proton-experimental` | 1 | Inferred |
| Linux ELF, aarch64 (`e_machine` `0xB7`) | `SteamLinuxRuntime_4-arm64` | 0 | Inferred: native | | Linux ELF, aarch64 (`e_machine` `0xB7`) | `SteamLinuxRuntime_4-arm64` | 0 | Verified: starts, but natively (see below) |
| Linux ELF, x86-64 (`0x3E`) | `SteamLinuxRuntime_4` | 0 | Inferred: runs through FEX; the least certain row | | Linux ELF, x86-64 (`0x3E`) | `SteamLinuxRuntime_4` | 0 | Verified not to start: the runtime isn't installed (see below) |
| Shell script | the runtime of the Linux binary beside it, else `SteamLinuxRuntime_4-arm64` | 0 | Guess | | Shell script | the runtime of the Linux binary beside it, else `SteamLinuxRuntime_4-arm64` | 0 | Guess |
| Anything else (32-bit Linux, other CPUs, DLLs, data) | refused with a message | | | | Anything else (32-bit Linux, other CPUs, DLLs, data) | refused with a message | | |
@@ -142,12 +142,30 @@ splits that string is **not checked**.
server, which only accepts requests from its own page (see server, which only accepts requests from its own page (see
[frame-control.md](frame-control.md#how-it-works)). [frame-control.md](frame-control.md#how-it-works)).
## To check on a headset ## Checked on a headset
- [ ] `create-shortcut` registers a title and it shows in the library under its id. Tested 2026-09-26 on a Frame (BUILD_ID 20260922.6101926) with small static test
- [ ] An aarch64 build launches in `SteamLinuxRuntime_4-arm64`. programs and PuTTY's official 64-bit `putty.exe`, through both the command line
- [ ] An x86-64 Windows `.exe` launches under Proton Experimental through FEX. and the app (inspect, install job, ▶, Remove, and install links):
- [ ] An x86-64 Linux build launches in `SteamLinuxRuntime_4` through FEX.
- [ ] `steam-devkit-rpc run-game` starts it, and `steamos-delete` removes the shortcut. - [x] `create-shortcut` registers a title; it shows in the Steam library and in
- [ ] How Steam splits a start command with a quoted path. **Sideloaded titles**, and Steam maps it to the chosen compat tool.
- [x] `steam-devkit-rpc run-game` starts it (Steam logs `devkit run-game: started
devkit game "<id>"`), and Remove (`steamos-delete`) deletes the files, the
shortcut and the Proton prefix.
- [x] A quoted path in the start command is fine: Steam runs
`proton waitforexitandrun "/home/steamos/devkit-game/<id>/<exe>"`.
- [x] An x86-64 Windows `.exe` runs under **Proton 11 (stable)** through FEX
(ARM64EC) inside the Steam Linux Runtime 4.0 ARM64 container; PuTTY stayed up.
Proton Experimental wasn't installed at the time (it was downloading), so it's
untested. A Go-built x86-64 test program crashed in `libarm64ecfex.dll`
(a FEX limitation with that program, not the sideloading).
- [ ] **An aarch64 build runs natively, not in `SteamLinuxRuntime_4-arm64`**:
Steam records the mapping (`CompatToolMapping`, `compat_log.txt`) but launches
the devkit title without the runtime's `_v2-entry-point` prefix. Fine for a
self-contained build; a build that needs the runtime's libraries may not start.
- [ ] **An x86-64 Linux build doesn't start**: Steam logs `Tool 4183110 "Steam
Linux Runtime 4.0" is found for appID …, but is not installed`, and the Frame
doesn't install that x86-64 runtime for a devkit title (a `steam://install/4183110`
request did nothing).
- [ ] Whether these titles open as flat panels or need anything VR-specific. - [ ] Whether these titles open as flat panels or need anything VR-specific.
+13 -6
View File
@@ -65,15 +65,23 @@ The script only asks for the password if the pairing below doesn't work.
## Password-free pairing (SteamOS devkit service) ## Password-free pairing (SteamOS devkit service)
**Inferred from Valve's source ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service), From Valve's source ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service),
[steamos-devkit](https://gitlab.steamos.cloud/devkit/steamos-devkit) client); not yet [steamos-devkit](https://gitlab.steamos.cloud/devkit/steamos-devkit) client). **Verified on a
verified on a Frame.** SteamOS's devkit service is what Valve's Devkit Client Frame 2026-09-26** (BUILD_ID 20260922.6101926): the service runs with Developer Mode
uses to pair. `scripts/connect.sh` and `ui/frame_connect.py` try it first: on, `properties.json` answers with `"login": "steamos"`, the headset advertises
`_steamos-devkit._tcp` as `frame`, and `/register` needs pairing mode (below). The
approve prompt and key install are not verified yet. SteamOS's devkit service is
what Valve's Devkit Client uses to pair. `scripts/connect.sh` and
`ui/frame_connect.py` try it first:
- The headset serves HTTP on port **32000** and advertises mDNS - The headset serves HTTP on port **32000** and advertises mDNS
`_steamos-devkit._tcp`. `GET /properties.json` gives the `login` user; the `_steamos-devkit._tcp`. `GET /properties.json` gives the `login` user; the
script uses it as `User` (unless you set `FRAME_USER`, or it says `root`), script uses it as `User` (unless you set `FRAME_USER`, or it says `root`),
for the password fallback too, and keeps it on re-runs. for the password fallback too, and keeps it on re-runs.
- **Open Steam Settings → Developer → Pair new host in the headset first.**
Otherwise `/register` answers at once with `403` `"please put the Steam client
in pairing mode: Settings -> Developer -> Pair new host"` (verified). The
scripts say so and keep asking for 2 minutes while you open it.
- `POST /register` with `ssh-rsa <key> <comment> 900b919520e4cf601998a71eec318fec` - `POST /register` with `ssh-rsa <key> <comment> 900b919520e4cf601998a71eec318fec`
(a fixed token from Valve's client) shows an approve prompt inside the (a fixed token from Valve's client) shows an approve prompt inside the
headset naming the comment (`frame-control@<your computer>`). It waits 30 s, headset naming the comment (`frame-control@<your computer>`). It waits 30 s,
@@ -89,8 +97,7 @@ uses to pair. `scripts/connect.sh` and `ui/frame_connect.py` try it first:
to copying the ed25519 key with the Developer Mode password, as before. to copying the ed25519 key with the Developer Mode password, as before.
Anyone on your network can send the request, so only approve a prompt you Anyone on your network can send the request, so only approve a prompt you
started. Whether the Frame runs this service with Developer Mode on is the started. `curl http://<frame-ip>:32000/properties.json` shows whether the service is up.
unverified part: `curl http://frame.local:32000/properties.json` answers the question.
`~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS `~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS
updates (inferred from Deck; the Frame uses the same A/B image scheme). updates (inferred from Deck; the Frame uses the same A/B image scheme).
+10 -4
View File
@@ -182,17 +182,23 @@ pair_with_devkit() {
comment="frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" comment="frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')"
[[ "$comment" == "frame-control@" ]] && comment="frame-control@computer" [[ "$comment" == "frame-control@" ]] && comment="frame-control@computer"
body="ssh-rsa $(awk '{print $2}' "$DEVKIT_KEY.pub") $comment $MAGIC_PHRASE" body="ssh-rsa $(awk '{print $2}' "$DEVKIT_KEY.pub") $comment $MAGIC_PHRASE"
print " Approve the pairing request in the headset (it waits about 30 seconds)" print " In the headset: Steam Settings > Developer > Pair new host, then approve the request"
# The headset refuses at once unless Steam is on its "Pair new host" screen
# (verified on a Frame, 2026-09-26), so keep asking for 2 minutes while it's opened.
local deadline=$(( SECONDS + 120 ))
while true; do
if ! resp=$(print -r -- "$body" | curl -sS --noproxy '*' -m 60 -H 'Content-Type: text/plain' \ if ! resp=$(print -r -- "$body" | curl -sS --noproxy '*' -m 60 -H 'Content-Type: text/plain' \
--data-binary @- -w '\n%{http_code}' "$(devkit_url /register)" 2>&1); then --data-binary @- -w '\n%{http_code}' "$(devkit_url /register)" 2>&1); then
devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1 devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1
fi fi
code=${resp##*$'\n'} code=${resp##*$'\n'}
text=${resp%$'\n'*} text=${resp%$'\n'*}
if [[ "$code" != 2* ]]; then [[ "$code" == 2* ]] && break
err=$(print -r -- "$text" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) err=$(print -r -- "$text" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
devkit_why="devkit pairing failed: ${err:-${text:-HTTP $code}}"; return 1 devkit_why="devkit pairing failed: ${err:-${text:-HTTP $code}}"
fi [[ "$devkit_why" == *"pairing mode"* ]] && (( SECONDS < deadline )) || return 1
sleep 3
done
# The approval is what turns sshd on, so it may take a moment to answer. # The approval is what turns sshd on, so it may take a moment to answer.
local i local i
for i in {1..10}; do for i in {1..10}; do
+28 -2
View File
@@ -22,6 +22,7 @@ PUB = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@old\n"
class StubDevkit(BaseHTTPRequestHandler): class StubDevkit(BaseHTTPRequestHandler):
"""Answers like steamos-devkit-service; `reply` picks the /register outcome.""" """Answers like steamos-devkit-service; `reply` picks the /register outcome."""
reply = (200, b"Registered\n") reply = (200, b"Registered\n")
replies = [] # if set, each /register takes the next one instead of `reply`
properties = {"txtvers": 1, "login": "steamos", "settings": "{}", "devkit1": ["devkit-1"]} properties = {"txtvers": 1, "login": "steamos", "settings": "{}", "devkit1": ["devkit-1"]}
bodies = [] bodies = []
@@ -41,7 +42,7 @@ class StubDevkit(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"])) body = self.rfile.read(int(self.headers["Content-Length"]))
StubDevkit.bodies.append((self.path, self.headers["Content-Type"], body)) StubDevkit.bodies.append((self.path, self.headers["Content-Type"], body))
code, text = self.reply code, text = StubDevkit.replies.pop(0) if StubDevkit.replies else self.reply
self.send_response(code) self.send_response(code)
self.send_header("Content-type", "text/plain") self.send_header("Content-type", "text/plain")
self.end_headers() self.end_headers()
@@ -63,6 +64,7 @@ class DevkitPairing(unittest.TestCase):
def setUp(self): def setUp(self):
StubDevkit.reply = (200, b"Registered\n") StubDevkit.reply = (200, b"Registered\n")
StubDevkit.bodies = [] StubDevkit.bodies = []
StubDevkit.replies = []
self.said = [] self.said = []
self._say, fc.say = fc.say, self.said.append self._say, fc.say = fc.say, self.said.append
@@ -104,7 +106,31 @@ class DevkitPairing(unittest.TestCase):
path, ctype, body = StubDevkit.bodies[0] path, ctype, body = StubDevkit.bodies[0]
self.assertEqual((path, ctype), ("/register", "text/plain")) self.assertEqual((path, ctype), ("/register", "text/plain"))
self.assertEqual(body.decode(), fc.register_body(PUB, "frame-control@test")) self.assertEqual(body.decode(), fc.register_body(PUB, "frame-control@test"))
self.assertTrue(any("Approve the pairing request" in s for s in self.said)) self.assertTrue(any("Pair new host" in s for s in self.said))
NOT_PAIRING = (403, b'{"error": "devkit approve-ssh-key: please put the Steam client in pairing mode: '
b'Settings -> Developer -> Pair new host"}')
def test_pair_waits_for_pairing_mode(self):
# The headset refuses until Steam is on "Pair new host", then prompts.
StubDevkit.replies = [self.NOT_PAIRING, self.NOT_PAIRING, (200, b"Registered\n")]
sleep, fc.time.sleep = fc.time.sleep, lambda s: None
try:
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port)
finally:
fc.time.sleep = sleep
self.assertIsNone(reason)
self.assertEqual(len(StubDevkit.bodies), 3)
def test_pair_gives_up_without_pairing_mode(self):
StubDevkit.reply = self.NOT_PAIRING
wait, fc.PAIRING_MODE_WAIT = fc.PAIRING_MODE_WAIT, 0
try:
reason = fc.devkit_pair("127.0.0.1", PUB, "c", self.port)
finally:
fc.PAIRING_MODE_WAIT = wait
self.assertIn("pairing mode", reason)
self.assertEqual(len(StubDevkit.bodies), 1)
def test_pair_refused_falls_back(self): def test_pair_refused_falls_back(self):
StubDevkit.reply = (403, b'{"error": "timeout - Steam did not respond to the pairing request"}') StubDevkit.reply = (403, b'{"error": "timeout - Steam did not respond to the pairing request"}')
+2 -2
View File
@@ -109,10 +109,10 @@ class Targets(unittest.TestCase):
self.assertEqual((p['target'], p['runtime']), ('game.arm64', 'SteamLinuxRuntime_4-arm64')) self.assertEqual((p['target'], p['runtime']), ('game.arm64', 'SteamLinuxRuntime_4-arm64'))
self.assertEqual(p['runtimes'], ['SteamLinuxRuntime_4-arm64']) self.assertEqual(p['runtimes'], ['SteamLinuxRuntime_4-arm64'])
def test_x86_64_linux_build_is_marked_inferred(self): def test_x86_64_linux_build_warns_it_may_not_start(self):
p = self.plan({'game.x86_64': elf(0x3E)}, 'game') p = self.plan({'game.x86_64': elf(0x3E)}, 'game')
self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4') self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4')
self.assertIn('inferred', p['note']) self.assertIn("won't start", p['note'])
def test_native_arm64_beats_x86_64(self): def test_native_arm64_beats_x86_64(self):
p = self.plan({'game.x86_64': elf(0x3E, pad=900), 'game.arm64': elf(0xB7)}, 'game') p = self.plan({'game.x86_64': elf(0x3E, pad=900), 'game.arm64': elf(0xB7)}, 'game')
+12 -2
View File
@@ -58,11 +58,14 @@ def say(msg):
# port 32000: GET /properties.json names the user to log in as; POST /register with # port 32000: GET /properties.json names the user to log in as; POST /register with
# "ssh-rsa <key> <comment> <magic>" shows an approve prompt in the headset (the # "ssh-rsa <key> <comment> <magic>" shows an approve prompt in the headset (the
# comment is what it displays, 30 s to answer), then installs the key and turns sshd on. # comment is what it displays, 30 s to answer), then installs the key and turns sshd on.
# The prompt only appears while Steam is on Settings > Developer > Pair new host;
# otherwise /register answers 403 "please put the Steam client in pairing mode".
DEVKIT_PORT = 32000 DEVKIT_PORT = 32000
DEVKIT_SERVICE = "_steamos-devkit._tcp" DEVKIT_SERVICE = "_steamos-devkit._tcp"
MAGIC_PHRASE = "900b919520e4cf601998a71eec318fec" # fixed token Valve's client appends MAGIC_PHRASE = "900b919520e4cf601998a71eec318fec" # fixed token Valve's client appends
REGISTER_TIMEOUT = 60 REGISTER_TIMEOUT = 60
PAIRING_MODE_WAIT = 120 # seconds to keep asking while the user opens "Pair new host"
# A LAN host: never go through an HTTP(S)_PROXY from the environment. # A LAN host: never go through an HTTP(S)_PROXY from the environment.
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) _opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
@@ -141,8 +144,15 @@ def devkit_pair(host, pub, comment, port=DEVKIT_PORT, on_login=None):
return f"devkit service not reachable on port {port}: {why(e)}" return f"devkit service not reachable on port {port}: {why(e)}"
if login and on_login: if login and on_login:
on_login(login) on_login(login)
say(" Approve the pairing request in the headset (it waits about 30 seconds)") say(" In the headset: Steam Settings > Developer > Pair new host, then approve the request")
ok, msg = register(host, register_body(pub, comment), port) body = register_body(pub, comment)
ok, msg = register(host, body, port)
# The headset refuses at once unless Steam is on its "Pair new host" screen
# (verified on a Frame, 2026-09-26), so keep asking while the user opens it.
deadline = time.monotonic() + PAIRING_MODE_WAIT
while not ok and "pairing mode" in msg and time.monotonic() < deadline:
time.sleep(3)
ok, msg = register(host, body, port)
return None if ok else f"devkit pairing failed: {msg}" return None if ok else f"devkit pairing failed: {msg}"
+3 -1
View File
@@ -399,7 +399,9 @@ def runtime_for(target, found=()):
if fmt == 'elf' and arch == 'arm64': if fmt == 'elf' and arch == 'arm64':
return 'SteamLinuxRuntime_4-arm64', 'Native ARM64 Linux build.' return 'SteamLinuxRuntime_4-arm64', 'Native ARM64 Linux build.'
if fmt == 'elf' and arch == 'x86_64': if fmt == 'elf' and arch == 'x86_64':
return 'SteamLinuxRuntime_4', 'x86-64 Linux build: runs through FEX (inferred, not yet checked).' return 'SteamLinuxRuntime_4', ("x86-64 Linux build: probably won't start. It needs the x86-64 Steam "
"Linux Runtime 4.0, which the Frame didn't install for a sideloaded title "
"(2026-09-26). Use an ARM64 or Windows build if there is one.")
raise FrameError(f"{target['path']} is a {arch} Linux program; the Frame runs ARM64 and x86-64 (through FEX) only") raise FrameError(f"{target['path']} is a {arch} Linux program; the Frame runs ARM64 and x86-64 (through FEX) only")