Files
web-terminal/ios/Packages/WireProtocol/Sources/WireProtocol/HostEndpoint.swift
Yaojia Wang cbaa08daba feat(ios): W0 scaffold + day-1 spike + WireProtocol frozen contract + TestSupport doubles
T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton
T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan
T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx
T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles
Verify: independent agent re-ran all acceptance — 6/6 PASS
2026-07-04 21:19:30 +02:00

90 lines
4.0 KiB
Swift

import Foundation
/// A paired web-terminal host (frozen contract, plan §3.1). The SINGLE point of
/// derivation for the `Origin` header and the WS URL hand-assembling either
/// anywhere else is a review CRITICAL (plan §5.1 / T-iOS-9 ).
///
/// Derivations are computed once at init and stored immutably:
/// - `originHeader` = `<scheme>://<host>[:<port>]`, omitting the scheme's
/// default port (http/80, https/443), scheme+host lowercased identical to
/// browser Origin serialization. The server normalises BOTH sides via
/// `new URL()` before comparing protocol/hostname/port (src/http/origin.ts:31-51).
/// - `wsURL` = same host+port, scheme httpws / httpswss, path replaced by
/// `WireConstants.wsPath`; query/fragment/credentials dropped.
public struct HostEndpoint: Sendable, Equatable, Codable {
/// The URL the user dialed: `http(s)://<host>[:<port>]`. Any path, query,
/// fragment or credentials it carries are ignored by the derivations.
public let baseURL: URL
/// Derived WS endpoint (`ws(s):///term`). Stored at init; contract-wise a
/// read-only property, per plan §3.1.
public let wsURL: URL
/// Derived `Origin` header value see type doc. Never hand-assemble.
public let originHeader: String
private static let wsSchemeByHTTPScheme = ["http": "ws", "https": "wss"]
private static let defaultPortByScheme = ["http": 80, "https": 443]
/// Validating init: the URL must be http(s) with a non-empty host, else nil
/// (QR-scan payloads are untrusted external input reject early, plan §5).
public init?(baseURL: URL) {
guard let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true),
let scheme = components.scheme?.lowercased(),
let wsScheme = Self.wsSchemeByHTTPScheme[scheme],
let rawHost = components.host, !rawHost.isEmpty,
let wsURL = Self.deriveWSURL(from: components, wsScheme: wsScheme)
else { return nil }
self.baseURL = baseURL
self.wsURL = wsURL
self.originHeader = Self.deriveOrigin(
scheme: scheme, host: rawHost.lowercased(), port: components.port
)
}
private static func deriveOrigin(scheme: String, host: String, port: Int?) -> String {
// IPv6 literals need brackets in an Origin. URLComponents.host has
// returned them both bare and pre-bracketed across Foundation versions
// wrap idempotently.
let needsBrackets = host.contains(":") && !host.hasPrefix("[")
let serializedHost = needsBrackets ? "[\(host)]" : host
guard let port, port != defaultPortByScheme[scheme] else {
return "\(scheme)://\(serializedHost)"
}
return "\(scheme)://\(serializedHost):\(port)"
}
private static func deriveWSURL(from components: URLComponents, wsScheme: String) -> URL? {
var wsComponents = components
wsComponents.scheme = wsScheme
wsComponents.path = WireConstants.wsPath
wsComponents.query = nil
wsComponents.fragment = nil
wsComponents.user = nil
wsComponents.password = nil
return wsComponents.url
}
// MARK: - Codable (persisted via HostRegistry/Keychain re-validate on decode)
private enum CodingKeys: String, CodingKey {
case baseURL
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let url = try container.decode(URL.self, forKey: .baseURL)
guard let endpoint = HostEndpoint(baseURL: url) else {
throw DecodingError.dataCorruptedError(
forKey: .baseURL, in: container,
debugDescription: "baseURL is not an http(s) URL with a host"
)
}
self = endpoint
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(baseURL, forKey: .baseURL)
}
}