mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 10:00:34 +02:00
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>
83 lines
4.4 KiB
Swift
83 lines
4.4 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
/// Pairing: the Developer Mode password is used once, to add this phone's own
|
|
/// key to the Frame. It isn't stored.
|
|
struct SetupView: View {
|
|
@ObservedObject var model: AppModel
|
|
@State private var host = ""
|
|
@State private var user = "steamos"
|
|
@State private var password = ""
|
|
@FocusState private var focus: Field?
|
|
private enum Field { case host, user, password }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14))
|
|
Text("Connect to your Steam Frame").font(.title2.bold())
|
|
Text("See what the headset sees, install games and Android apps, and send files and text, from this \(model.deviceName).")
|
|
.foregroundStyle(Color.frameMuted)
|
|
}
|
|
.padding(.vertical, 6)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
Section {
|
|
Label("On the Frame, open Steam Settings → System and turn on Developer Mode.", systemImage: "1.circle")
|
|
Label("Then Developer → Set User Password.", systemImage: "2.circle")
|
|
Label("Enter the headset's address and that password here, once.", systemImage: "3.circle")
|
|
} header: { Text("Before you start") }
|
|
Section {
|
|
TextField("frame.local or 192.168.1.20", text: $host)
|
|
.textContentType(.URL).keyboardType(.URL).autocorrectionDisabled().textInputAutocapitalization(.never)
|
|
.focused($focus, equals: .host).submitLabel(.next).onSubmit { focus = .password }
|
|
TextField("User", text: $user)
|
|
.autocorrectionDisabled().textInputAutocapitalization(.never).focused($focus, equals: .user)
|
|
SecureField("Developer Mode password", text: $password)
|
|
.textContentType(.password).focused($focus, equals: .password).submitLabel(.go).onSubmit(pair)
|
|
} header: { Text("Headset") } footer: {
|
|
Text("The password is only used to add this \(model.deviceName)'s own SSH key to the Frame; it isn't saved. The Frame and this \(model.deviceName) need to be on the same network, or both on Tailscale.")
|
|
}
|
|
Section {
|
|
Button(action: pair) {
|
|
Text("Pair").frame(maxWidth: .infinity).fontWeight(.semibold)
|
|
}
|
|
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty)
|
|
if model.settings != nil {
|
|
Button("Use \(model.settings!.host) again") { Task { await model.connect() } }
|
|
Button("Forget this headset", role: .destructive) { Task { await model.forget() } }
|
|
}
|
|
}
|
|
Section {
|
|
Text(model.authorizedKeysLine)
|
|
.font(.system(.caption2, design: .monospaced)).lineLimit(3).textSelection(.enabled)
|
|
Button("Copy this \(model.deviceName)'s key") { UIPasteboard.general.string = model.authorizedKeysLine }
|
|
Button("Connect with the key") {
|
|
let (h, u) = (host, user)
|
|
Task { await model.useKey(host: h, user: u) }
|
|
}
|
|
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
} header: { Text("Or add the key yourself") } footer: {
|
|
Text("If you already reach the Frame over SSH, add this line to ~/.ssh/authorized_keys there, then connect without a password.")
|
|
}
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.frameBackground)
|
|
.navigationTitle("Frame Control")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.onAppear {
|
|
host = model.settings?.host ?? "frame.local"
|
|
user = model.settings?.user ?? "steamos"
|
|
}
|
|
}
|
|
|
|
private func pair() {
|
|
let (h, u, p) = (host, user, password)
|
|
password = ""
|
|
Task { await model.pair(host: h, user: u, password: p) }
|
|
}
|
|
}
|