Files
QueueCube/ios/QueueCube/Views/Settings View/AddServerView.swift
2025-10-10 23:13:50 -07:00

281 lines
9.1 KiB
Swift

//
// AddServerView.swift
// QueueCube
//
// Created by James Magahern on 6/10/25.
//
import Network
import SwiftUI
struct AddServerView: View
{
let onAddServer: (Server) -> Void
@State var model = ViewModel()
var body: some View {
Form {
// Manual Entry
Section(.manual) {
TextField(.serverURL, text: $model.serverURL)
.autocapitalization(.none)
.autocorrectionDisabled()
.keyboardType(.URL)
switch model.validationState {
case .empty:
EmptyView()
case .validating:
HStack {
ProgressView()
.progressViewStyle(.circular)
Text(.validating)
}
case .notValid:
HStack {
Image(systemName: "x.circle.fill")
Text(.unableToConnect)
}
.foregroundStyle(.red)
case .valid:
HStack {
Image(systemName: "checkmark.circle.fill")
Text(.serverIsOnline)
}
.foregroundStyle(.green)
Button {
// Force unwrap, since we validated it at this point.
let server = Server(baseURL: URL(string: model.serverURL)!)
onAddServer(server)
} label: {
HStack {
Spacer()
Text(.addServer)
Spacer()
}
}
}
}
// Discovered
Section(.discovered) {
if model.discoveredServers.isEmpty {
HStack {
ProgressView()
.progressViewStyle(.circular)
Text(.findingServers)
}
} else {
List(model.discoveredServers) { (server: DiscoveredEndpoint) in
Button {
resolveEndpoint(server)
} label: {
HStack {
Image(systemName: "network")
Text("\(server.displayName)")
.bold()
Spacer()
if model.resolvingServers.contains(server) {
ProgressView()
.progressViewStyle(.circular)
}
}
}
.tint(.primary)
}
}
}
}
.task {
model.startDiscovery()
}
}
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
Task {
model.resolvingServers.insert(endpoint)
let server = try await endpoint.resolve()
onAddServer(server)
model.resolvingServers.remove(endpoint)
}
}
// MARK: - Types
@Observable
class ViewModel
{
var serverURL: String = ""
var validationURL: String = ""
var validationState: ValidationState = .empty
var discoveredServers: [DiscoveredEndpoint] = []
var resolvingServers = Set<DiscoveredEndpoint>()
private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
private var validationTimer: Timer? = nil
init() {
observeForValidation()
}
public func startDiscovery() {
browser.browseResultsChangedHandler = { [weak self] results, changes in
guard let self else { return }
self.discoveredServers = results.map { DiscoveredEndpoint(result: $0) }
}
browser.stateUpdateHandler = { state in
if case .failed(let error) = state {
print("Discovery error: \(error)")
}
}
browser.start(queue: .global(qos: .userInitiated))
}
private func observeForValidation() {
withObservationTracking {
_ = serverURL
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
setNeedsValidation()
observeForValidation()
}
}
}
private func setNeedsValidation() {
self.validationURL = self.serverURL
self.validationTimer?.invalidate()
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
self?.validateSettings()
}
}
private func validateSettings() {
guard !validationURL.isEmpty else {
validationState = .empty
return
}
self.validationState = .validating
Task {
do {
let url = try URL(string: validationURL).try_unwrap()
let api = API(baseURL: url)
_ = try await api.fetchNowPlayingInfo()
self.validationState = .valid
if validationURL != serverURL {
self.serverURL = self.validationURL
}
} catch {
print("Validation failed: \(error)")
if !validationURL.hasSuffix("/api") {
// Try adding /api and validating again.
self.validationURL = serverURL.appending("/api")
validateSettings()
} else {
self.validationState = .notValid
}
}
}
}
// MARK: - Types
enum ValidationState
{
case empty
case validating
case notValid
case valid
}
}
}
struct DiscoveredEndpoint: Identifiable, Hashable
{
let endpoint: NWEndpoint
let serviceName: String
var displayName: String {
serviceName.queueCubeServiceName
}
var id: String { serviceName }
init(result: NWBrowser.Result) {
self.endpoint = result.endpoint
switch result.endpoint {
case .service(name: let name, type: _, domain: _, interface: _):
self.serviceName = name
default:
self.serviceName = "(Unknown)"
break
}
}
func resolve() async throws -> Server {
return try await withCheckedThrowingContinuation { continuation in
let connection = NWConnection(to: endpoint, using: .tcp)
connection.stateUpdateHandler = { state in
switch state {
case .preparing: break
case .ready:
// xxx: is this really the right way to do this? Maybe we should not try to turn this into a URL.
if case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint {
let address = switch host {
case .name(let string, _): string
case .ipv4(let iPv4Address): iPv4Address.debugDescription
case .ipv6(let iPv6Address): iPv6Address.debugDescription
default: "unknown"
}
if let server = Server(serviceName: serviceName, host: address, port: port.rawValue) {
continuation.resume(returning: server)
} else {
continuation.resume(throwing: Self.Error.urlError)
}
} else {
continuation.resume(throwing: Self.Error.endpointIncorrect)
}
connection.cancel()
case .cancelled:
// expected
break
case .failed(let error):
continuation.resume(throwing: error)
connection.cancel()
default:
break
}
}
connection.start(queue: .global(qos: .userInitiated))
}
}
// MARK: - Types
enum Error: Swift.Error
{
case cancelledConnection
case endpointIncorrect
case urlError
}
}