diff --git a/README.md b/README.md index bc763b1..8be10f2 100644 --- a/README.md +++ b/README.md @@ -149,10 +149,11 @@ computer. password once in a terminal window. If it can't find the Frame, type the IP address from the Frame's Quick Settings. - Before asking for the password it tries Valve's SteamOS devkit pairing: if - the headset shows a pairing request, approve it and no password is needed. - (**Inferred from Valve's source** ([steamos-devkit-service](https://gitlab.steamos.cloud/devkit/steamos-devkit-service)), - not yet verified on a Frame; see [SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).) + Before asking for the password it tries Valve's SteamOS devkit pairing: in + the headset, open Steam Settings → Developer → **Pair new host** and approve + the request, and no password is needed. (The service and the pairing-mode + 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 same network. For anywhere else, see [Tailscale](docs/tailscale.md). diff --git a/docs/sideloading.md b/docs/sideloading.md index 741a360..6ff60e4 100644 --- a/docs/sideloading.md +++ b/docs/sideloading.md @@ -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`, 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, x86-64 (`0x3E`) | `SteamLinuxRuntime_4` | 0 | Inferred: runs through FEX; the least certain row | +| Linux ELF, aarch64 (`e_machine` `0xB7`) | `SteamLinuxRuntime_4-arm64` | 0 | Verified: starts, but natively (see below) | +| 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 | | 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 [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. -- [ ] An aarch64 build launches in `SteamLinuxRuntime_4-arm64`. -- [ ] An x86-64 Windows `.exe` launches under Proton Experimental through FEX. -- [ ] An x86-64 Linux build launches in `SteamLinuxRuntime_4` through FEX. -- [ ] `steam-devkit-rpc run-game` starts it, and `steamos-delete` removes the shortcut. -- [ ] How Steam splits a start command with a quoted path. +Tested 2026-09-26 on a Frame (BUILD_ID 20260922.6101926) with small static test +programs and PuTTY's official 64-bit `putty.exe`, through both the command line +and the app (inspect, install job, ▶, Remove, and install links): + +- [x] `create-shortcut` registers a title; it shows in the Steam library and in + **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 ""`), 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//"`. +- [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. diff --git a/docs/ssh.md b/docs/ssh.md index aa11407..93aac3b 100644 --- a/docs/ssh.md +++ b/docs/ssh.md @@ -65,15 +65,23 @@ The script only asks for the password if the pairing below doesn't work. ## Password-free pairing (SteamOS devkit service) -**Inferred 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 -verified on a Frame.** SteamOS's devkit service is what Valve's Devkit Client -uses to pair. `scripts/connect.sh` and `ui/frame_connect.py` try it first: +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). **Verified on a +Frame 2026-09-26** (BUILD_ID 20260922.6101926): the service runs with Developer Mode +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 `_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`), 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 900b919520e4cf601998a71eec318fec` (a fixed token from Valve's client) shows an approve prompt inside the headset naming the comment (`frame-control@`). 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. 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 -unverified part: `curl http://frame.local:32000/properties.json` answers the question. +started. `curl http://:32000/properties.json` shows whether the service is up. `~/.ssh/authorized_keys` lives under `/home`, which SteamOS keeps across OS updates (inferred from Deck; the Frame uses the same A/B image scheme). diff --git a/scripts/connect.sh b/scripts/connect.sh index 0be75a5..00bcb41 100755 --- a/scripts/connect.sh +++ b/scripts/connect.sh @@ -182,17 +182,23 @@ pair_with_devkit() { comment="frame-control@$(hostname -s | tr -cs 'A-Za-z0-9._-' '-' | sed 's/^[-.]*//; s/[-.]*$//')" [[ "$comment" == "frame-control@" ]] && comment="frame-control@computer" 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)" - 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 - devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1 - fi - code=${resp##*$'\n'} - text=${resp%$'\n'*} - if [[ "$code" != 2* ]]; then + 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' \ + --data-binary @- -w '\n%{http_code}' "$(devkit_url /register)" 2>&1); then + devkit_why="devkit pairing failed: no answer (${${resp##*curl: }%%$'\n'*})"; return 1 + fi + code=${resp##*$'\n'} + text=${resp%$'\n'*} + [[ "$code" == 2* ]] && break 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 - fi + devkit_why="devkit pairing failed: ${err:-${text:-HTTP $code}}" + [[ "$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. local i for i in {1..10}; do diff --git a/tests/test_connect.py b/tests/test_connect.py index d2b8187..b3366bd 100644 --- a/tests/test_connect.py +++ b/tests/test_connect.py @@ -22,6 +22,7 @@ PUB = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC+/x= frame-control@old\n" class StubDevkit(BaseHTTPRequestHandler): """Answers like steamos-devkit-service; `reply` picks the /register outcome.""" 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"]} bodies = [] @@ -41,7 +42,7 @@ class StubDevkit(BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers["Content-Length"])) 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_header("Content-type", "text/plain") self.end_headers() @@ -63,6 +64,7 @@ class DevkitPairing(unittest.TestCase): def setUp(self): StubDevkit.reply = (200, b"Registered\n") StubDevkit.bodies = [] + StubDevkit.replies = [] self.said = [] self._say, fc.say = fc.say, self.said.append @@ -104,7 +106,31 @@ class DevkitPairing(unittest.TestCase): path, ctype, body = StubDevkit.bodies[0] self.assertEqual((path, ctype), ("/register", "text/plain")) 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): StubDevkit.reply = (403, b'{"error": "timeout - Steam did not respond to the pairing request"}') diff --git a/tests/test_frame_titles.py b/tests/test_frame_titles.py index 8ca91af..6a0354c 100644 --- a/tests/test_frame_titles.py +++ b/tests/test_frame_titles.py @@ -109,10 +109,10 @@ class Targets(unittest.TestCase): self.assertEqual((p['target'], p['runtime']), ('game.arm64', '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') 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): p = self.plan({'game.x86_64': elf(0x3E, pad=900), 'game.arm64': elf(0xB7)}, 'game') diff --git a/ui/frame_connect.py b/ui/frame_connect.py index bc3be68..9b75ded 100644 --- a/ui/frame_connect.py +++ b/ui/frame_connect.py @@ -58,11 +58,14 @@ def say(msg): # port 32000: GET /properties.json names the user to log in as; POST /register with # "ssh-rsa " 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. +# 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_SERVICE = "_steamos-devkit._tcp" MAGIC_PHRASE = "900b919520e4cf601998a71eec318fec" # fixed token Valve's client appends 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. _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)}" if login and on_login: on_login(login) - say(" Approve the pairing request in the headset (it waits about 30 seconds)") - ok, msg = register(host, register_body(pub, comment), port) + say(" In the headset: Steam Settings > Developer > Pair new host, then approve the request") + 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}" diff --git a/ui/frame_titles.py b/ui/frame_titles.py index a1d63d8..cf20507 100644 --- a/ui/frame_titles.py +++ b/ui/frame_titles.py @@ -399,7 +399,9 @@ def runtime_for(target, found=()): if fmt == 'elf' and arch == 'arm64': return 'SteamLinuxRuntime_4-arm64', 'Native ARM64 Linux build.' 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")