mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 10:00:34 +02:00
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>
This commit is contained in:
1 parent
0f770dc88a
commit
13187e8f6a
33 files changed
+2459
-34
No files matched your search
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user