import Foundation import SwiftUI /// One ProdDash installation the viewer shows: the name its tab carries and /// the address it answers at. struct Connection: Codable, Identifiable, Equatable { var id = UUID() var name: String /// Always a full http(s) URL — see `Address.normalize`. var address: String var url: URL? { URL(string: address) } /// The name, or the host when nobody gave it one. var displayName: String { let trimmed = name.trimmingCharacters(in: .whitespaces) return trimmed.isEmpty ? (url?.host ?? address) : trimmed } var displayAddress: String { url.map(Address.display) ?? address } } enum Address { /// ProdDash's port unless someone changed it (config/proddash.json). static let defaultPort = 24500 /// What someone typed, as the URL ProdDash answers at: `10.3.11.152` and /// `10.3.11.152:24500` both become `http://10.3.11.152:24500/`. A typed /// scheme is taken at its word — `http://host` means port 80. A path is /// kept, so an address can open a particular page. static func normalize(_ text: String) -> URL? { var typed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !typed.isEmpty else { return nil } let hasScheme = typed.contains("://") if !hasScheme { typed = "http://" + typed } guard var parts = URLComponents(string: typed), let scheme = parts.scheme?.lowercased(), scheme == "http" || scheme == "https", let host = parts.host, !host.isEmpty else { return nil } parts.scheme = scheme if !hasScheme && parts.port == nil { parts.port = defaultPort } if parts.path.isEmpty { parts.path = "/" } return parts.url } /// Scheme, host and port — what decides whether a link stays in the viewer. static func origin(_ url: URL) -> String? { guard let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased() else { return nil } let port = url.port ?? (scheme == "https" ? 443 : 80) return "\(scheme)://\(host):\(port)" } /// For people to read: no trailing slash on a bare address. static func display(_ url: URL) -> String { var text = url.absoluteString if url.path == "/" && url.query == nil && url.fragment == nil && text.hasSuffix("/") { text.removeLast() } return text } /// Just the scheme, host and port, for people: `https://host`. static func originDisplay(_ url: URL) -> String { var parts = URLComponents() parts.scheme = url.scheme parts.host = url.host parts.port = url.port return parts.string ?? url.absoluteString } /// `host:port`, the way the error messages name a place. static func place(_ url: URL?) -> String { guard let url, let host = url.host else { return "that address" } return url.port.map { "\(host):\($0)" } ?? host } } /// The connections, in tab order, kept in the viewer's preferences. final class ConnectionStore: ObservableObject { static let shared = ConnectionStore() private static let key = "connections" @Published private(set) var connections: [Connection] { didSet { save() } } private init() { let saved = UserDefaults.standard.data(forKey: Self.key) connections = saved.flatMap { try? JSONDecoder().decode([Connection].self, from: $0) } ?? [] } func add(_ connection: Connection) { connections.append(connection) } func update(_ connection: Connection) { guard let index = connections.firstIndex(where: { $0.id == connection.id }) else { return } connections[index] = connection } func remove(_ id: UUID) { connections.removeAll { $0.id == id } } func move(from source: IndexSet, to destination: Int) { connections.move(fromOffsets: source, toOffset: destination) } private func save() { guard let data = try? JSONEncoder().encode(connections) else { return } UserDefaults.standard.set(data, forKey: Self.key) } } /// Network failures in words an operator can act on. enum NetworkProblem { static func describe(_ error: Error, url: URL?) -> String { let ns = error as NSError let place = Address.place(url) guard ns.domain == NSURLErrorDomain else { return ns.localizedDescription } switch ns.code { case NSURLErrorCannotConnectToHost: return "Nothing is answering at \(place). ProdDash may not be running there, or it may use another port." case NSURLErrorTimedOut: return "\(place) didn't answer in time." case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed: return "\(url?.host ?? place) can't be found on the network." case NSURLErrorNotConnectedToInternet: return "This Mac isn't on a network — or macOS is keeping ProdDash Viewer off it. Check System Settings → Privacy & Security → Local Network." case NSURLErrorNetworkConnectionLost: return "The connection to \(place) dropped." case NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateHasBadDate, NSURLErrorServerCertificateNotYetValid: return "\(place) offered a secure connection this Mac doesn't trust." default: return ns.localizedDescription } } } /// Asks an address whether ProdDash is there, for the connection sheet. enum Probe { enum Answer: Equatable { case prodDash case somethingElse case noAnswer(String) } private static let session: URLSession = { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 4 config.timeoutIntervalForResource = 6 return URLSession(configuration: config) }() /// `/api/modules` is the first thing every dashboard asks for, and it /// answers without a PIN — so `{"modules": …}` there means ProdDash. static func ask(_ url: URL) async -> Answer { guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return .somethingElse } parts.path = "/api/modules" parts.query = nil parts.fragment = nil guard let target = parts.url else { return .somethingElse } do { let (data, response) = try await session.data(from: target) let ok = (response as? HTTPURLResponse)?.statusCode == 200 let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any] return ok && body?["modules"] != nil ? .prodDash : .somethingElse } catch { return .noAnswer(NetworkProblem.describe(error, url: url)) } } }