mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +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
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user