mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.
Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
FNV-1a filename fallback, FoundationNetworking imports for URLSession
types, corelibs URLCache diskPath: initializer, get-only
waitsForConnectivity, delegate-based SSE transport where
URLSession.bytes(for:) does not exist
158 lines
6.4 KiB
Swift
158 lines
6.4 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking // URLSession types live here on Linux
|
|
#endif
|
|
import HapiProtocol
|
|
|
|
/// Why a pairing attempt failed, shaped for direct UI presentation
|
|
/// (the pairing screen's error states).
|
|
public enum PairingFailure: Error, Equatable, Sendable {
|
|
/// The typed/scanned hub URL is not an absolute http(s) URL.
|
|
case invalidHubURL
|
|
/// The hub could not be reached, or whatever answered `GET /health` was
|
|
/// not a healthy HAPI hub (non-2xx, undecodable body, `status != "ok"`).
|
|
case unreachable
|
|
/// The hub speaks a different protocol generation than this build.
|
|
case protocolMismatch(hubVersion: Int, supportedVersion: Int)
|
|
/// `POST /api/auth` answered 401 — the access token is wrong or rotated.
|
|
case invalidAccessToken
|
|
/// `POST /api/auth` answered an unexpected status.
|
|
case hubError(status: Int)
|
|
/// The exchange succeeded but the credentials could not be persisted
|
|
/// (Keychain write failure).
|
|
case storageFailure
|
|
}
|
|
|
|
/// Result of a successful pairing.
|
|
public struct PairedHub: Equatable, Sendable {
|
|
/// Normalized hub origin — the key everything else uses.
|
|
public let hubUrl: String
|
|
/// Claims of the freshly issued JWT (`ns == "default"` gates owner-only
|
|
/// surfaces). `nil` only if the hub issued an unparseable token.
|
|
public let claims: JWTClaims?
|
|
|
|
public init(hubUrl: String, claims: JWTClaims?) {
|
|
self.hubUrl = hubUrl
|
|
self.claims = claims
|
|
}
|
|
}
|
|
|
|
/// The pairing/unpairing sequence, kept UI-free so it is testable through the
|
|
/// ``HTTPPerforming`` seam (see `PairingLogicTests`).
|
|
///
|
|
/// `pair` follows `docs/api/client-contract/auth.md`:
|
|
/// 1. normalize the hub URL (`HubURLNormalization`),
|
|
/// 2. `GET /health` — reachability plus `protocolVersion` compatibility,
|
|
/// 3. `POST /api/auth {accessToken}` — the token is opaque, passed verbatim,
|
|
/// 4. persist ``HubCredentials`` (access token + first JWT) in the credential
|
|
/// store, register the hub, and make it active.
|
|
///
|
|
/// `unpair` deletes the credentials, drops the hub from the registry, and
|
|
/// reports which hub (if any) is active afterwards. The app layer owns
|
|
/// everything session-shaped (building/tearing down `HubSession`).
|
|
public struct HubPairingService: Sendable {
|
|
private let registry: HubRegistry
|
|
private let credentialStore: any CredentialStoring
|
|
private let performer: any HTTPPerforming
|
|
private let now: @Sendable () -> Date
|
|
|
|
public init(
|
|
registry: HubRegistry,
|
|
credentialStore: any CredentialStoring,
|
|
performer: any HTTPPerforming = URLSessionHTTPPerformer.shared,
|
|
now: @escaping @Sendable () -> Date = { Date() }
|
|
) {
|
|
self.registry = registry
|
|
self.credentialStore = credentialStore
|
|
self.performer = performer
|
|
self.now = now
|
|
}
|
|
|
|
/// Pairs with the hub at `rawHubUrl` using `accessToken`. On success the
|
|
/// hub is registered, active, and its credentials are persisted (an
|
|
/// existing record for the same hub is replaced — re-pairing refreshes a
|
|
/// rotated token). Throws ``PairingFailure``.
|
|
public func pair(rawHubUrl: String, accessToken: String) async throws -> PairedHub {
|
|
guard let normalized = HubURLNormalization.normalize(rawHubUrl),
|
|
let baseURL = URL(string: normalized) else {
|
|
throw PairingFailure.invalidHubURL
|
|
}
|
|
let token = accessToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !token.isEmpty else {
|
|
throw PairingFailure.invalidAccessToken
|
|
}
|
|
|
|
// Throwaway client: both calls are unauthenticated, so the auth
|
|
// manager is wiring-only and never consulted.
|
|
let client = APIClient(
|
|
baseURL: baseURL,
|
|
authManager: AuthManager(baseURL: baseURL, credentialStore: credentialStore, performer: performer),
|
|
performer: performer
|
|
)
|
|
|
|
// 1. Reachability + protocol compatibility.
|
|
let health: HubHealthResponse
|
|
do {
|
|
health = try await client.health()
|
|
} catch {
|
|
// Non-2xx, transport failure, or a 200 that does not decode as a
|
|
// hub health body — nothing at this address we can pair with.
|
|
throw PairingFailure.unreachable
|
|
}
|
|
guard health.status == "ok" else {
|
|
throw PairingFailure.unreachable
|
|
}
|
|
// The protocol generation is a single integer (shared/src/version.ts);
|
|
// this build understands exactly one.
|
|
guard health.protocolVersion == ProtocolVersion.supported else {
|
|
throw PairingFailure.protocolMismatch(
|
|
hubVersion: health.protocolVersion,
|
|
supportedVersion: ProtocolVersion.supported
|
|
)
|
|
}
|
|
|
|
// 2. Token exchange.
|
|
let auth: AuthResponse
|
|
do {
|
|
auth = try await client.authenticate(accessToken: token)
|
|
} catch let error as APIError {
|
|
throw error.status == 401
|
|
? PairingFailure.invalidAccessToken
|
|
: PairingFailure.hubError(status: error.status)
|
|
} catch {
|
|
throw PairingFailure.unreachable
|
|
}
|
|
|
|
// 3. Persist. Credentials first: a hub registered without
|
|
// credentials would restore as paired-but-dead.
|
|
do {
|
|
try credentialStore.store(HubCredentials(
|
|
hubUrl: normalized,
|
|
accessToken: token,
|
|
jwt: auth.token,
|
|
jwtObtainedAt: Int(now().timeIntervalSince1970 * 1000)
|
|
))
|
|
} catch {
|
|
throw PairingFailure.storageFailure
|
|
}
|
|
registry.register(normalized)
|
|
registry.setActiveHub(normalized)
|
|
|
|
return PairedHub(hubUrl: normalized, claims: JWT.claims(from: auth.token))
|
|
}
|
|
|
|
/// Removes a hub: credentials deleted, registry entry dropped, active
|
|
/// selection falls back per ``HubRegistry`` rules. Returns the hub that is
|
|
/// active afterwards, or `nil` when none remain (back to unpaired).
|
|
///
|
|
/// TODO(M4a-push): once device push registration exists, unregister the
|
|
/// device here first, while a valid JWT is still obtainable.
|
|
@discardableResult
|
|
public func unpair(hubUrl: String) -> String? {
|
|
let normalized = HubURLNormalization.normalize(hubUrl) ?? hubUrl
|
|
try? credentialStore.deleteCredentials(forHub: normalized)
|
|
registry.remove(normalized)
|
|
return registry.activeHub
|
|
}
|
|
}
|