git-subtree-dir: ios git-subtree-mainline:52968df567git-subtree-split:2220a0d4f2
281 lines
9.1 KiB
Swift
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
|
|
}
|
|
}
|