Files
saphid--frame-control/ios/FrameControl/Views/RootView.swift
T
saphidandClaude Opus 5.5 13187e8f6a iPhone and iPad app: the same features, served from the Frame
An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI)
connects with its own SSH key (Citadel), copies the server and helpers to
~/.cache/frame-control/<version> on the Frame once per version, starts
ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the
page through an SSH tunnel. The server exits when the phone disconnects.

Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each
`ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and
phone share one code path. Android display goes through podman exec there, as
the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a
per-session key. Power actions take the Developer Mode password via sudo -S.
--port 0 now prints the port it took.

Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch
screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop
opening in their iOS apps, and a password dialog for power.

App: pairing with the Developer Mode password once (never stored) or with a
key the user adds; host key pinned on first use; plain-language connection
errors with quiet retries; frame-control://install links; alerts and confirms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 11:18:15 +10:00

71 lines
2.5 KiB
Swift

import SwiftUI
struct RootView: View {
@ObservedObject var model: AppModel
var body: some View {
ZStack {
Color.frameBackground.ignoresSafeArea()
switch model.phase {
case .setup:
SetupView(model: model)
case .connecting(let step):
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
case .failed(let message):
FailedView(message: message, canRetry: model.settings != nil,
retry: { Task { await model.connect() } }, change: { model.showSetup() })
case .ready(let url):
WebShell(url: url, model: model).ignoresSafeArea()
}
}
.preferredColorScheme(.dark)
.tint(.frameBlue)
}
}
extension Color {
static let frameBackground = Color(red: 0.055, green: 0.078, blue: 0.106)
static let framePanel = Color(red: 0.118, green: 0.137, blue: 0.161)
static let frameBlue = Color(red: 0.102, green: 0.624, blue: 1.0)
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
}
struct ConnectingView: View {
let step: String
let host: String?
let cancel: () -> Void
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
ProgressView().controlSize(.large)
Text(step).font(.headline).multilineTextAlignment(.center)
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
Button("Change headset", action: cancel).padding(.top, 8)
}
.padding(32)
}
}
struct FailedView: View {
let message: String
let canRetry: 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())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
if canRetry { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
if canRetry {
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
}
Button(canRetry ? "Change headset" : "Back", action: change)
}
.padding(32)
.frame(maxWidth: 480)
}
}