iPhone app: test pairing and power against a Holo Core stand-in

Valve publishes no Steam Frame OS image, so tests/frame-container builds the
Frame's SSH surface on Valve and Collabora's Holo Core aarch64 base: a
steamos user with a password and sudo, OpenSSH with keys and passwords,
Python, and a systemctl that only records requests.

Against it from the Simulator: password pairing, the host-key pin, the power
password check. Found and fixed: a changed host key or a refused login said
"Can't reach the Frame" and retried forever; they now say "Pair with the
Frame again" and offer that. The server's key rejection now says the header
may be wrong, not only missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-27 15:44:42 +10:00
1 parent db75d1ca71
commit aafd2dbda8
8 files changed
+78 -14

No files matched your search

+10 -3
View File
@@ -78,9 +78,16 @@ screenshots, a file upload (checked on the Frame), a background install job, and
the power password check (a wrong password is refused). The server on the Frame
exits within seconds of the app closing.
Not yet exercised: pairing with the password (only the add-the-key route),
Android display changes through podman (no Android app was running), a real
sleep/restart/shut down, and a physical iPhone.
Against a stand-in built on Valve's Holo Core aarch64 base
([tests/frame-container](../tests/frame-container)), since Valve publishes no
Frame OS image: pairing with the password (key added with the right
permissions, host key pinned, password stored nowhere), the power password
check (a wrong or missing password refused; the right one reaches `systemctl`),
a changed host key refused with "Pair with the Frame again", and a wrong
pairing password reported the same way.
Not yet exercised: Android display changes through podman (no Android app was
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
+11 -3
View File
@@ -70,7 +70,9 @@ final class AppModel: ObservableObject {
settings = target
} catch {
guard mine == attempt else { return }
fail((error as? FrameFailure)?.message ?? FrameLink.describe(error, host: target.host), retry: false)
let failure = error as? FrameFailure
fail(failure?.message ?? FrameLink.describe(error, host: target.host), retry: false,
needsPairing: failure?.needsPairing ?? false)
return
}
await connect()
@@ -197,11 +199,17 @@ final class AppModel: ObservableObject {
forwarder?.stop()
if let link { await link.close() } // ends its server too
guard current(), !(error is CancellationError) else { return }
fail((error as? FrameFailure)?.message ?? FrameLink.describe(error, host: settings.host), retry: true)
let failure = error as? FrameFailure
fail(failure?.message ?? FrameLink.describe(error, host: settings.host), retry: !(failure?.needsPairing ?? false),
needsPairing: failure?.needsPairing ?? false)
}
}
private func fail(_ message: String, retry: Bool) {
/// Whether the last failure needs the user to pair again rather than wait.
@Published private(set) var needsPairing = false
private func fail(_ message: String, retry: Bool, needsPairing: Bool = false) {
self.needsPairing = needsPairing
phase = .failed(message)
retrying = retry && settings != nil
guard retrying else { return }
@@ -14,6 +14,11 @@ struct FrameControlApp: App {
// and connect to FRAME_TEST_HOST with it (`simctl launch` passes
// SIMCTL_CHILD_FRAME_TEST_HOST through as FRAME_TEST_HOST).
print("FRAME_CONTROL_KEY: \(model.authorizedKeysLine)")
// FRAME_TEST_PAIR="host|user|password" runs the real password pairing.
if model.settings == nil, let pair = ProcessInfo.processInfo.environment["FRAME_TEST_PAIR"] {
let f = pair.components(separatedBy: "|")
if f.count == 3 { await model.pair(host: f[0], user: f[1], password: f[2]); return }
}
if model.settings == nil, let host = ProcessInfo.processInfo.environment["FRAME_TEST_HOST"] {
await model.useKey(host: host, user: "steamos")
return
+9 -2
View File
@@ -13,7 +13,12 @@ struct FrameSettings: Codable, Equatable {
struct FrameFailure: LocalizedError {
let message: String
init(_ message: String) { self.message = message }
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
var needsPairing = false
init(_ message: String, needsPairing: Bool = false) {
self.message = message
self.needsPairing = needsPairing
}
var errorDescription: String? { message }
}
@@ -52,7 +57,9 @@ final class FrameLink: @unchecked Sendable {
hostKeyValidator: .custom(hostKey), reconnect: .never, connectTimeout: .seconds(8))
return FrameLink(client: client)
} catch {
throw FrameFailure(describe(error, host: settings.host))
let text = String(describing: error)
throw FrameFailure(describe(error, host: settings.host),
needsPairing: error is PinnedHostKey.Changed || text.contains("allAuthenticationOptionsFailed"))
}
}
+9 -5
View File
@@ -12,7 +12,7 @@ struct RootView: View {
case .connecting(let step):
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
case .failed(let message):
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying,
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
retry: { Task { await model.connect() } }, change: { model.showSetup() })
case .ready(let url):
WebShell(url: url, model: model).ignoresSafeArea()
@@ -51,19 +51,23 @@ struct FailedView: View {
let message: String
let canRetry: Bool
let retrying: Bool
let needsPairing: Bool
let retry: () -> Void
let change: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "wifi.exclamationmark").font(.system(size: 44)).foregroundStyle(.orange)
Text("Can't reach the Frame").font(.title3.bold())
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
.font(.system(size: 44)).foregroundStyle(.orange)
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
if canRetry {
if needsPairing {
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
} else if canRetry {
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
}
Button(canRetry ? "Change headset" : "Back", action: change)
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
}
.padding(32)
.frame(maxWidth: 480)
+13
View File
@@ -0,0 +1,13 @@
# A stand-in for the Frame's SSH surface, on Valve's Holo Core aarch64 base (the
# Arch Linux ARM64 port the Frame's SteamOS is built on).
FROM registry.gitlab.steamos.cloud/holo/holo-core-aarch64-preview/base-devel:latest
RUN pacman -Sy --noconfirm --needed openssh sudo python rsync procps-ng && pacman -Scc --noconfirm
# The Frame's user, with a Developer Mode style password and sudo, as on SteamOS.
RUN useradd -m -s /bin/bash steamos && echo 'steamos:frame-test-pw' | chpasswd \
&& echo 'steamos ALL=(ALL) ALL' > /etc/sudoers.d/steamos && chmod 440 /etc/sudoers.d/steamos
# SteamOS's sshd: keys and passwords, no keyboard-interactive.
RUN ssh-keygen -A && printf 'PasswordAuthentication yes\nKbdInteractiveAuthentication no\nUsePAM yes\n' > /etc/ssh/sshd_config.d/10-frame.conf
# No systemd here: a systemctl that records power requests instead of acting.
RUN printf '#!/bin/sh\necho "systemctl $*" >> /tmp/power-requests.log\n' > /usr/local/bin/systemctl && chmod +x /usr/local/bin/systemctl
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D", "-e"]
+20
View File
@@ -0,0 +1,20 @@
# A Frame stand-in for testing without the headset
Valve publishes no Steam Frame OS image. This builds the closest thing: Valve and
Collabora's [Holo Core aarch64 preview](https://www.collabora.com/news-and-blog/news-and-events/building-an-arch-linux-aarch64-port-for-holo-core.html)
(the Arch Linux ARM64 base the Frame's SteamOS is built on) with the Frame's SSH
surface: a `steamos` user with a password and sudo, OpenSSH taking keys and
passwords, Python and rsync. `systemctl` only records what it's asked to do.
It exercises pairing with the password, the host-key pin, the server running on
the "Frame" (FRAME_LOCAL=1) and the power password check. It has no SteamVR,
Steam, battery, cameras or Lepton, so those panels are empty.
```sh
docker build --platform linux/arm64 -t frame-holo-test tests/frame-container
docker run -d --name frame-holo -p 127.0.0.1:2222:22 frame-holo-test
# password: frame-test-pw. In the iPhone app (Simulator), pair with 127.0.0.1:2222.
docker exec frame-holo cat /tmp/power-requests.log # what power actions asked for
```
On a Mac without Docker: `brew install colima && colima start --arch aarch64 --vm-type vz`.
+1 -1
View File
@@ -1283,7 +1283,7 @@ class Handler(BaseHTTPRequestHandler):
# trigger a headset capture and display it.
api = urlparse(self.path).path.startswith("/api/")
if (self.command == "POST" or api) and not secrets.compare_digest(self.headers.get("X-Frame-UI") or "", UI_KEY):
self.send_json({"error": "missing X-Frame-UI header"}, 403)
self.send_json({"error": "missing or wrong X-Frame-UI header"}, 403)
return False
return True