170 lines
6.7 KiB
Swift
170 lines
6.7 KiB
Swift
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))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|