iPhone app: a first screen that says exactly what to do

The app finds the Frame by itself (Valve's _steamos-devkit._tcp Bonjour
service, else the saved address or frame.local on port 22), so setup is two
numbered steps: wake your Frame (Looking… / Found ✓, and after a few seconds
exactly what to check), then the Developer Mode password and Connect. Manual
address and key options sit under Other ways to connect.

A paired Frame that doesn't answer is almost always asleep: instead of an
error, Waiting for your Frame says how to wake it and connects as soon as its
SSH port answers (checked every 3 s; 4 s after the stand-in came back).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-27 18:16:14 +10:00
1 parent 10bf18fd50
commit c711e136d4
7 files changed
+349 -54

No files matched your search

@@ -19,6 +19,7 @@
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; }; 9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; }; A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; }; DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; }; F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A11F3431826A26B247C0695 /* AppModel.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -36,6 +37,7 @@
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; }; 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; }; 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; }; 237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameFinder.swift; sourceTree = "<group>"; };
6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 6B5B6718C5EA77FA67F6B14C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6FBC8D0B5ED7BF1C06F99892 /* FrameControlTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = FrameControlTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; }; 8A11F3431826A26B247C0695 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
@@ -74,6 +76,7 @@
5064A5B6FE5B17B18E5FA4B8 /* SSH */ = { 5064A5B6FE5B17B18E5FA4B8 /* SSH */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */,
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */, 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */,
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */, EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */,
237D9AF04EEA257AB382F60E /* Keys.swift */, 237D9AF04EEA257AB382F60E /* Keys.swift */,
@@ -255,6 +258,7 @@
files = ( files = (
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */, F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */, 78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */, 12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */, 0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */,
4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */, 4622FE0F0D6499CD642C29A2 /* InstallLink.swift in Sources */,
+1 -1
View File
@@ -92,7 +92,7 @@ final class AppModel: ObservableObject {
} }
/// "host", "host:port" or "[v6]:port", plus a user name. /// "host", "host:port" or "[v6]:port", plus a user name.
static func parse(host: String, user: String) -> FrameSettings? { nonisolated static func parse(host: String, user: String) -> FrameSettings? {
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces)) var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") { if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
let rest = target.host[target.host.index(after: close)...] let rest = target.host[target.host.index(after: close)...]
+5 -1
View File
@@ -43,8 +43,12 @@
<key>NSAllowsLocalNetworking</key> <key>NSAllowsLocalNetworking</key>
<true/> <true/>
</dict> </dict>
<key>NSBonjourServices</key>
<array>
<string>_steamos-devkit._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>Frame Control connects to your Steam Frame over your local network with SSH.</string> <string>Frame Control finds your Steam Frame on your network and connects to it.</string>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict> <dict>
<key>UIColorName</key> <key>UIColorName</key>
+125
View File
@@ -0,0 +1,125 @@
import Foundation
import Network
/// Finds the Frame on the local network so nobody has to type its address.
/// A Frame in Developer Mode advertises Valve's devkit service over Bonjour
/// (`_steamos-devkit._tcp`); failing that, `fallback` (the saved address, or
/// frame.local) is checked by opening its SSH port. Both repeat until stopped.
@MainActor
final class FrameFinder: ObservableObject {
struct Found: Equatable {
let host: String // what to connect to
let name: String // what to call it
}
@Published private(set) var found: Found?
/// When the search began, to tell "still looking" from "can't find it".
@Published private(set) var since = Date()
private var browser: NWBrowser?
private var probeTask: Task<Void, Never>?
private var fallback = "frame.local"
func start(fallback: String?) {
stop()
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
found = nil
since = Date()
browse()
probeTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let host = self.fallback
if self.found == nil, await Self.sshAnswers(host: host) {
self.found = Found(host: host, name: host)
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
}
func stop() {
browser?.cancel()
browser = nil
probeTask?.cancel()
probeTask = nil
}
private func browse() {
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { [weak self] results, _ in
for result in results {
guard case let .service(name, _, _, _) = result.endpoint else { continue }
Self.resolve(result.endpoint) { host in
Task { @MainActor in
guard let self, let host else { return }
// A found device is used over the fallback probe.
self.found = Found(host: host, name: name)
}
}
}
}
browser.start(queue: .main)
self.browser = browser
}
/// The device's IP address: connect to the service and read where it went.
nonisolated private static func resolve(_ endpoint: NWEndpoint, done: @escaping @Sendable (String?) -> Void) {
let connection = NWConnection(to: endpoint, using: .tcp)
let once = Once()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
var host: String?
if case let .hostPort(h, _)? = connection.currentPath?.remoteEndpoint {
host = "\(h)".components(separatedBy: "%").first // drop an IPv6 interface suffix
}
connection.cancel()
if once.claim() { done(host) }
case .failed, .cancelled:
if once.claim() { done(nil) }
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + 5) {
connection.cancel()
if once.claim() { done(nil) }
}
}
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
nonisolated static func sshAnswers(host address: String) async -> Bool {
var host = address, port: UInt16 = 22
if let target = AppModel.parse(host: address, user: "steamos") {
host = target.host
port = UInt16(target.port)
}
return await sshAnswers(host: host, port: port)
}
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
await withCheckedContinuation { (c: CheckedContinuation<Bool, Never>) in
let connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(rawValue: port) ?? 22, using: .tcp)
let once = Once()
connection.stateUpdateHandler = { state in
switch state {
case .ready:
connection.cancel()
if once.claim() { c.resume(returning: true) }
case .failed, .waiting:
connection.cancel()
if once.claim() { c.resume(returning: false) }
default:
break
}
}
connection.start(queue: .global())
DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
connection.cancel()
if once.claim() { c.resume(returning: false) }
}
}
}
}
+46
View File
@@ -11,6 +11,9 @@ struct RootView: View {
SetupView(model: model) SetupView(model: model)
case .connecting(let step): case .connecting(let step):
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() } ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
case .failed(let message) where model.retrying && !model.needsPairing:
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
case .failed(let message): case .failed(let message):
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing, FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
retry: { Task { await model.connect() } }, change: { model.showSetup() }) retry: { Task { await model.connect() } }, change: { model.showSetup() })
@@ -73,3 +76,46 @@ struct FailedView: View {
.frame(maxWidth: 480) .frame(maxWidth: 480)
} }
} }
/// A paired Frame that isn't answering is almost always asleep: say how to wake
/// it, and connect the moment it does (its SSH port is checked every 3 s).
struct WaitingView: View {
let host: String
let detail: String
let deviceName: String
let reachable: () -> Void
let change: () -> Void
@State private var pulse = false
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17))
.opacity(pulse ? 1 : 0.55)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
Text("Waiting for your Frame").font(.title3.bold())
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 10) {
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
}
.padding(14)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
Button("Connect to a different Frame", action: change).font(.footnote)
}
.padding(28)
.frame(maxWidth: 480)
.onAppear { pulse = true }
.task(id: host) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
reachable()
return
}
}
}
}
}
+166 -51
View File
@@ -1,82 +1,197 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
/// Pairing: the Developer Mode password is used once, to add this phone's own /// First run: two steps. Wake the Frame (the app finds it by itself), then type the
/// key to the Frame. It isn't stored. /// Developer Mode password once. Everything else waits under "Other ways to connect".
struct SetupView: View { struct SetupView: View {
@ObservedObject var model: AppModel @ObservedObject var model: AppModel
@State private var host = "" @StateObject private var finder = FrameFinder()
@State private var user = "steamos"
@State private var password = "" @State private var password = ""
@FocusState private var focus: Field? @State private var manualHost = ""
private enum Field { case host, user, password } @State private var user = "steamos"
@State private var showOther = false
@State private var showHelp = false
@FocusState private var passwordFocused: Bool
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
private var host: String {
let typed = manualHost.trimmingCharacters(in: .whitespaces)
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
}
var body: some View { var body: some View {
NavigationStack { ScrollView {
Form { VStack(alignment: .leading, spacing: 22) {
Section { header
VStack(alignment: .leading, spacing: 10) { StepCard(number: 1, title: "Wake your Frame", done: finder.found != nil) { wakeStep }
Image("AppIconImage").resizable().frame(width: 64, height: 64).clipShape(RoundedRectangle(cornerRadius: 14)) StepCard(number: 2, title: "Enter its Developer Mode password", done: false) { passwordStep }
otherWays
}
.padding(20)
.frame(maxWidth: 560)
.frame(maxWidth: .infinity)
}
.scrollDismissesKeyboard(.interactively)
.background(Color.frameBackground)
.onAppear {
manualHost = model.settings?.host ?? ""
user = model.settings?.user ?? "steamos"
var fallback = model.settings?.host
#if DEBUG
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
#endif
finder.start(fallback: fallback)
}
.onDisappear { finder.stop() }
// After a few seconds of not finding it, say exactly what to check.
.task(id: finder.since) {
try? await Task.sleep(nanoseconds: 8_000_000_000)
showHelp = true
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 8) {
Image("AppIconImage").resizable().frame(width: 56, height: 56).clipShape(RoundedRectangle(cornerRadius: 13))
Text("Connect to your Steam Frame").font(.title2.bold()) 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).") Text("One time only. After this, the app connects by itself whenever your Frame is awake.")
.foregroundStyle(Color.frameMuted) .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") // MARK: step 1
Label("Then Developer → Set User Password.", systemImage: "2.circle")
Label("Enter the headset's address and that password here, once.", systemImage: "3.circle") @ViewBuilder private var wakeStep: some View {
} header: { Text("Before you start") } if let found = finder.found {
Section { Label {
TextField("frame.local or 192.168.1.20", text: $host) VStack(alignment: .leading, spacing: 2) {
.textContentType(.URL).keyboardType(.URL).autocorrectionDisabled().textInputAutocapitalization(.never) Text("Found your Frame").fontWeight(.semibold)
.focused($focus, equals: .host).submitLabel(.next).onSubmit { focus = .password } Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)")
TextField("User", text: $user) .font(.footnote).foregroundStyle(Color.frameMuted)
.autocorrectionDisabled().textInputAutocapitalization(.never).focused($focus, equals: .user) }
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
}
} else {
HStack(spacing: 10) {
ProgressView()
Text("Looking for it on this network…").foregroundStyle(Color.frameMuted)
}
Text("Put the headset on, or press its power button, so it's awake.")
if showHelp {
VStack(alignment: .leading, spacing: 10) {
Text("Still can't see it? Check:").font(.subheadline.weight(.semibold))
Tip(icon: "wifi", text: "The Frame and this \(model.deviceName) are on the same Wi-Fi.")
Tip(icon: "hammer", text: "Developer Mode is on: on the Frame, Steam Settings → System → Enable Developer Mode.")
Tip(icon: "network", text: "Local Network is allowed for Frame Control: \(model.deviceName) Settings → Apps → Frame Control.")
}
.padding(.top, 4)
}
}
}
// MARK: step 2
@ViewBuilder private var passwordStep: some View {
SecureField("Developer Mode password", text: $password) SecureField("Developer Mode password", text: $password)
.textContentType(.password).focused($focus, equals: .password).submitLabel(.go).onSubmit(pair) .textContentType(.password)
} header: { Text("Headset") } footer: { .submitLabel(.go)
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.") .focused($passwordFocused)
.onSubmit(connect)
.padding(12)
.background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 10))
Text("Haven't set one? On the Frame: Steam Settings → Developer → Set User Password. It's only used now, to let this \(model.deviceName) in; it isn't saved.")
.font(.footnote).foregroundStyle(Color.frameMuted)
Button(action: connect) {
Text(finder.found == nil ? "Connect to \(host)" : "Connect")
.fontWeight(.semibold).frame(maxWidth: .infinity).padding(.vertical, 4)
} }
Section { .buttonStyle(.borderedProminent)
Button(action: pair) { .controlSize(.large)
Text("Pair").frame(maxWidth: .infinity).fontWeight(.semibold) .disabled(password.isEmpty)
} }
.disabled(host.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty)
if model.settings != nil { // MARK: everything else, out of the way
Button("Use \(model.settings!.host) again") { Task { await model.connect() } }
Button("Forget this headset", role: .destructive) { Task { await model.forget() } } private var otherWays: some View {
DisclosureGroup(isExpanded: $showOther) {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
.onSubmit { finder.start(fallback: manualHost) }
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
TextField("User", text: $user)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
Text("Typing an address here uses it instead of searching.").font(.caption).foregroundStyle(Color.frameMuted)
} }
} VStack(alignment: .leading, spacing: 6) {
Section { Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.")
Text(model.authorizedKeysLine) .font(.footnote).foregroundStyle(Color.frameMuted)
.font(.system(.caption2, design: .monospaced)).lineLimit(3).textSelection(.enabled) HStack {
Button("Copy this \(model.deviceName)'s key") { UIPasteboard.general.string = model.authorizedKeysLine } Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine }
Spacer()
Button("Connect with the key") { Button("Connect with the key") {
let (h, u) = (host, user) let (h, u) = (host, user)
Task { await model.useKey(host: h, user: u) } 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) if let saved = model.settings {
.background(Color.frameBackground) Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
.navigationTitle("Frame Control")
.navigationBarTitleDisplayMode(.inline)
} }
.onAppear { }
host = model.settings?.host ?? "frame.local" .padding(.top, 10)
user = model.settings?.user ?? "steamos" } label: {
Text("Other ways to connect").foregroundStyle(Color.frameMuted)
}
.onChange(of: manualHost) { _, value in
// A typed address replaces the search.
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
} }
} }
private func pair() { private func connect() {
guard !password.isEmpty else { passwordFocused = true; return }
let (h, u, p) = (host, user, password) let (h, u, p) = (host, user, password)
password = "" password = ""
finder.stop()
Task { await model.pair(host: h, user: u, password: p) } Task { await model.pair(host: h, user: u, password: p) }
} }
} }
/// A numbered step with a tick once it's done.
struct StepCard<Content: View>: View {
let number: Int
let title: String
let done: Bool
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
ZStack {
Circle().fill(done ? Color.green : Color.frameBlue).frame(width: 26, height: 26)
if done { Image(systemName: "checkmark").font(.caption.bold()) } else { Text("\(number)").font(.subheadline.bold()) }
}
.foregroundStyle(.white)
Text(title).font(.headline)
}
content
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 14))
}
}
struct Tip: View {
let icon: String
let text: String
var body: some View {
Label { Text(text).font(.subheadline).fixedSize(horizontal: false, vertical: true) } icon: { Image(systemName: icon).foregroundStyle(Color.frameBlue) }
}
}
+2 -1
View File
@@ -39,7 +39,8 @@ targets:
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
UIUserInterfaceStyle: Dark UIUserInterfaceStyle: Dark
NSLocalNetworkUsageDescription: Frame Control connects to your Steam Frame over your local network with SSH. NSLocalNetworkUsageDescription: Frame Control finds your Steam Frame on your network and connects to it.
NSBonjourServices: [_steamos-devkit._tcp]
NSAppTransportSecurity: NSAppTransportSecurity:
NSAllowsLocalNetworking: true NSAllowsLocalNetworking: true
LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp] LSApplicationQueriesSchemes: [ssh, sftp, steamlink, rdp]