mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
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:
1 parent
10bf18fd50
commit
c711e136d4
7 files changed
+360
-65
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 */,
|
||||||
|
|||||||
@@ -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)...]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
Text("Connect to your Steam Frame").font(.title2.bold())
|
otherWays
|
||||||
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)
|
.padding(20)
|
||||||
.background(Color.frameBackground)
|
.frame(maxWidth: 560)
|
||||||
.navigationTitle("Frame Control")
|
.frame(maxWidth: .infinity)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
}
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
|
.background(Color.frameBackground)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
host = model.settings?.host ?? "frame.local"
|
manualHost = model.settings?.host ?? ""
|
||||||
user = model.settings?.user ?? "steamos"
|
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 func pair() {
|
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("One time only. After this, the app connects by itself whenever your Frame is awake.")
|
||||||
|
.foregroundStyle(Color.frameMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: step 1
|
||||||
|
|
||||||
|
@ViewBuilder private var wakeStep: some View {
|
||||||
|
if let found = finder.found {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Found your Frame").fontWeight(.semibold)
|
||||||
|
Text(found.name == found.host ? found.host : "\(found.name) · \(found.host)")
|
||||||
|
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
.textContentType(.password)
|
||||||
|
.submitLabel(.go)
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.large)
|
||||||
|
.disabled(password.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: everything else, out of the way
|
||||||
|
|
||||||
|
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) {
|
||||||
|
Text("Already reach the Frame over SSH? Add this \(model.deviceName)'s key to ~/.ssh/authorized_keys there, then connect without a password.")
|
||||||
|
.font(.footnote).foregroundStyle(Color.frameMuted)
|
||||||
|
HStack {
|
||||||
|
Button("Copy key") { UIPasteboard.general.string = model.authorizedKeysLine }
|
||||||
|
Spacer()
|
||||||
|
Button("Connect with the key") {
|
||||||
|
let (h, u) = (host, user)
|
||||||
|
Task { await model.useKey(host: h, user: u) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let saved = model.settings {
|
||||||
|
Button("Forget \(saved.host)", role: .destructive) { Task { await model.forget() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
} 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 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
@@ -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]
|
||||||
|
|||||||
Reference in new issue
Block a user