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
90 lines
4.0 KiB
Swift
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 http→ws / https→wss, 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)
|
|
}
|
|
}
|