mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 22:00:25 +02:00
Primary tabs become Apps (Installed / Browse / Linux apps), Games (Installed / Owned / Store), Tools and Settings, with shortcuts 1-4 and one Add / Install chooser for catalogs, Steam, Linux apps, files and install links. Installed rows show identity, one state and Open/Stop; the rest moves to app details. Review fixes (#106): input, touch, approvals, artwork repair, keyboard helper delivery and catalogue jobs stay bound to the headset they were meant for; report preview follows the latest consent; failed Store reads are not an empty library; keyboard access to device details and the Store sheet; the iOS bundle ships the artwork renderer and OpenXR resources; typed addresses and custom SSH ports survive setup; WebKit crashes recover; native preferences survive relaunch; APK update rollback and repair stay retryable. Refs #101, #104, #106 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.4 KiB
Swift
134 lines
5.4 KiB
Swift
import Combine
|
|
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 search = FrameSearchState()
|
|
private let probe: @Sendable (String) async -> Bool
|
|
private let discoveryEnabled: Bool
|
|
|
|
init(probe: @escaping @Sendable (String) async -> Bool = { await FrameFinder.sshAnswers(host: $0) },
|
|
discoveryEnabled: Bool = true) {
|
|
self.probe = probe
|
|
self.discoveryEnabled = discoveryEnabled
|
|
}
|
|
|
|
func start(fallback: String?, manual: Bool = false) {
|
|
stop()
|
|
let mine = search.start(manualAddress: manual ? fallback : nil)
|
|
let host = FrameSearchState.address(manual: search.manualAddress ?? "", discovered: fallback)
|
|
found = nil
|
|
since = Date()
|
|
if search.discovers && discoveryEnabled { browse(generation: mine) }
|
|
probeTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
guard let self, self.search.accepts(mine) else { return }
|
|
if self.found == nil {
|
|
let answers = await self.probe(host)
|
|
guard !Task.isCancelled, self.search.accepts(mine) else { return }
|
|
if answers, self.found == nil { self.found = Found(host: host, name: host) }
|
|
}
|
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
search.stop()
|
|
browser?.cancel()
|
|
browser = nil
|
|
probeTask?.cancel()
|
|
probeTask = nil
|
|
}
|
|
|
|
private func browse(generation: UUID) {
|
|
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, self.search.accepts(generation), self.search.discovers, 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 {
|
|
guard let target = FrameSettings.parse(host: address, user: "steamos") else { return false }
|
|
return await sshAnswers(host: target.host, port: UInt16(target.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) }
|
|
}
|
|
}
|
|
}
|
|
}
|