// // ServerAddress.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import Foundation /// Controls whether server discovery may try plaintext HTTP after HTTPS fails. package enum ServerSchemePolicy: Sendable, Hashable { /// Try HTTPS first and permit HTTP fallback only for an unprefixed address. case automatic /// Use HTTPS only, preserving any port supplied by the user. case httpsOnly } /// A validated Jellyfin server base URL and its scheme provenance. package struct ServerAddress: Sendable, Hashable { /// The normalized base URL, including a trailing slash. package let baseURL: URL /// Whether the user supplied a scheme in the original address. package let schemeWasExplicit: Bool private let allowsInsecureFallback: Bool /// The candidates to probe, ordered from preferred to fallback URL. package var probeCandidates: [URL] { allowsInsecureFallback ? [baseURL, baseURL.withScheme("http")] : [baseURL] } /// Normalizes free-text onboarding fields into a validated server address. /// /// - Parameters: /// - address: A host name, IP address, or HTTP(S) URL, optionally with a path and port. /// - port: An optional separate TCP port field. /// - policy: The scheme policy selected by the user. /// - Returns: A validated server address whose base URL is ready for probing. /// - Throws: `ServerAddressError` when the input is empty, malformed, unsupported, or conflicting. package static func normalize( address rawAddress: String, port rawPort: String, policy: ServerSchemePolicy ) throws(ServerAddressError) -> ServerAddress { let address = rawAddress.trimmingCharacters(in: .whitespacesAndNewlines) let portText = rawPort.trimmingCharacters(in: .whitespacesAndNewlines) guard !address.isEmpty else { throw .emptyAddress } let schemeWasExplicit = address.range( of: #"^[A-Za-z][A-Za-z0-9+.-]*://"#, options: .regularExpression ) != nil let parseable = schemeWasExplicit ? address : "https://" + address guard var components = URLComponents(string: parseable) else { throw .malformedAddress } guard let parsedScheme = components.scheme?.lowercased() else { throw .malformedAddress } guard parsedScheme == "http" || parsedScheme == "https" else { throw .unsupportedScheme(parsedScheme) } guard let host = components.host, !host.isEmpty else { throw .missingHost } guard components.user == nil, components.password == nil else { throw .credentialsNotAllowed } guard components.query == nil, components.fragment == nil else { throw .queryOrFragmentNotAllowed } let addressPort = components.port let enteredPort: Int? if portText.isEmpty { enteredPort = nil } else { guard let value = Int(portText), (1...65_535).contains(value) else { throw .invalidPort(portText) } enteredPort = value } if addressPort != nil && enteredPort != nil { throw .conflictingPorts } var chosenPort = enteredPort ?? addressPort if enteredPort == nil, (parsedScheme == "http" && chosenPort == 80) || (parsedScheme == "https" && chosenPort == 443) { chosenPort = nil } components.scheme = policy == .httpsOnly ? "https" : parsedScheme components.port = chosenPort var path = components.path if path.isEmpty { path = "/" } else if !path.hasSuffix("/") { path += "/" } components.path = path guard let baseURL = components.url else { throw .malformedAddress } return ServerAddress( baseURL: baseURL, schemeWasExplicit: schemeWasExplicit, allowsInsecureFallback: !schemeWasExplicit && policy == .automatic ) } } private extension URL { func withScheme(_ scheme: String) -> URL { guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self } components.scheme = scheme return components.url ?? self } }