mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 02:00:19 +02:00
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:
1 parent
db75d1ca71
commit
aafd2dbda8
8 files changed
+78
-14
No files matched your search
+10
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user