From aafd2dbda814e41bd0eec5ae9d174d62bbe61454 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:44:42 +1000 Subject: [PATCH] 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) --- docs/iphone.md | 13 ++++++++++--- ios/FrameControl/App/AppModel.swift | 14 +++++++++++--- ios/FrameControl/App/FrameControlApp.swift | 5 +++++ ios/FrameControl/SSH/FrameLink.swift | 11 +++++++++-- ios/FrameControl/Views/RootView.swift | 14 +++++++++----- tests/frame-container/Dockerfile | 13 +++++++++++++ tests/frame-container/README.md | 20 ++++++++++++++++++++ ui/server.py | 2 +- 8 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 tests/frame-container/Dockerfile create mode 100644 tests/frame-container/README.md diff --git a/docs/iphone.md b/docs/iphone.md index 991260f..aba9ae4 100644 --- a/docs/iphone.md +++ b/docs/iphone.md @@ -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 diff --git a/ios/FrameControl/App/AppModel.swift b/ios/FrameControl/App/AppModel.swift index 27ee3d8..613328f 100644 --- a/ios/FrameControl/App/AppModel.swift +++ b/ios/FrameControl/App/AppModel.swift @@ -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 } diff --git a/ios/FrameControl/App/FrameControlApp.swift b/ios/FrameControl/App/FrameControlApp.swift index 840e5ce..00782bf 100644 --- a/ios/FrameControl/App/FrameControlApp.swift +++ b/ios/FrameControl/App/FrameControlApp.swift @@ -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 diff --git a/ios/FrameControl/SSH/FrameLink.swift b/ios/FrameControl/SSH/FrameLink.swift index 9c57ebc..4cf7f4d 100644 --- a/ios/FrameControl/SSH/FrameLink.swift +++ b/ios/FrameControl/SSH/FrameLink.swift @@ -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")) } } diff --git a/ios/FrameControl/Views/RootView.swift b/ios/FrameControl/Views/RootView.swift index 0d628bf..007003b 100644 --- a/ios/FrameControl/Views/RootView.swift +++ b/ios/FrameControl/Views/RootView.swift @@ -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) diff --git a/tests/frame-container/Dockerfile b/tests/frame-container/Dockerfile new file mode 100644 index 0000000..3915d6b --- /dev/null +++ b/tests/frame-container/Dockerfile @@ -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"] diff --git a/tests/frame-container/README.md b/tests/frame-container/README.md new file mode 100644 index 0000000..780d6e3 --- /dev/null +++ b/tests/frame-container/README.md @@ -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`. diff --git a/ui/server.py b/ui/server.py index e66cb42..ef22af6 100755 --- a/ui/server.py +++ b/ui/server.py @@ -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