Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Auth/HubRegistry.swift
T
weishu 1f0ec6184d feat(ios): HapiClient API transport + auth (A-M1b)
APIClient (final class, Sendable) with typed endpoints for the M2/M3 REST
surface behind an HTTPPerforming seam; actor AuthManager with single-flight
JWT refresh (POST /api/auth), proactive refresh 10 min before exp, terminal
authFailed state, and 401 -> refresh -> retry-once wiring per
docs/api/client-contract/auth.md; Keychain credential store (per-hub records
under run.hapi.companion) with CredentialStoring seam + in-memory double;
HubRegistry (ordered hubs + active hub in injectable UserDefaults, origin
normalization); payload-only JWT decoding; APIError {status, code, body}
parsing per errors.md; minimal multipart builder for M4c dictation; 256 MB
URLCache session for generated images.

Request/response wire models (SendMessageRequest, PermissionApproveRequest,
SpawnRequest/SpawnResponse, MessagesQuery, envelopes, RPC-wrapped shapes)
join HapiProtocol/Models mirroring shared/src/apiTypes.ts.

swift-testing coverage: JWT decode (padding/garbage/hostile exp), auth
single-flight (8 concurrent callers -> one exchange), 401 retry-once and
terminal paths, APIError body parsing, endpoint request construction
byte-checks (cursor queries, deliveryMode body, answers formats, explicit
null model reset) via a recording performer, hub URL normalization,
multipart bytes.
2026-08-17 15:20:03 +08:00

130 lines
5.0 KiB
Swift

import Foundation
/// Canonical form of a hub URL: the lowercased origin, nothing else.
///
/// Everything credential- or connection-shaped is keyed by this string
/// (Keychain accounts, registry entries, `AuthManager` storage keys), so the
/// same hub typed as `https://Hub.example.com/` and scanned as
/// `https://hub.example.com` collapses to one identity.
public enum HubURLNormalization {
/// `raw` → `scheme://host[:port]`, or `nil` when it is not an absolute
/// http(s) URL. Trims whitespace, lowercases scheme/host, drops any
/// path/query/fragment (including the trailing slash) and default ports.
public static func normalize(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
let components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased(),
scheme == "http" || scheme == "https",
var host = components.host?.lowercased(),
!host.isEmpty else {
return nil
}
// URLComponents strips the brackets from IPv6 literals; restore them
// (defensively: only when not already bracketed).
if host.contains(":") && !host.hasPrefix("[") {
host = "[\(host)]"
}
var origin = "\(scheme)://\(host)"
if let port = components.port {
let isDefault = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
if !isDefault {
origin += ":\(port)"
}
}
return origin
}
}
/// Ordered set of paired hubs plus the active selection, persisted in
/// `UserDefaults` (URLs only — secrets live in the ``CredentialStoring``
/// Keychain store).
///
/// `UserDefaults` is injectable for tests; all URLs are normalized on the way
/// in, so lookups never miss on formatting differences.
public final class HubRegistry: @unchecked Sendable {
public static let hubsDefaultsKey = "run.hapi.hubs"
public static let activeHubDefaultsKey = "run.hapi.activeHub"
private let defaults: UserDefaults
/// Serializes read-modify-write cycles on the stored array.
private let lock = NSLock()
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
/// Paired hub origins, in pairing order.
public var hubs: [String] {
lock.lock()
defer { lock.unlock() }
return storedHubs()
}
/// The active hub origin. Falls back to the first paired hub when the
/// stored selection is missing or no longer registered.
public var activeHub: String? {
lock.lock()
defer { lock.unlock() }
let hubs = storedHubs()
if let active = defaults.string(forKey: Self.activeHubDefaultsKey), hubs.contains(active) {
return active
}
return hubs.first
}
/// Normalizes and registers a hub. Returns the normalized origin, or
/// `nil` when `raw` is not a valid hub URL. The first registered hub
/// becomes active automatically; re-registering is a no-op.
@discardableResult
public func register(_ raw: String) -> String? {
guard let normalized = HubURLNormalization.normalize(raw) else { return nil }
lock.lock()
defer { lock.unlock() }
var hubs = storedHubs()
if !hubs.contains(normalized) {
hubs.append(normalized)
defaults.set(hubs, forKey: Self.hubsDefaultsKey)
}
if defaults.string(forKey: Self.activeHubDefaultsKey) == nil {
defaults.set(normalized, forKey: Self.activeHubDefaultsKey)
}
return normalized
}
/// Selects the active hub. Returns `false` when the URL is invalid or
/// not registered.
@discardableResult
public func setActiveHub(_ raw: String) -> Bool {
guard let normalized = HubURLNormalization.normalize(raw) else { return false }
lock.lock()
defer { lock.unlock() }
guard storedHubs().contains(normalized) else { return false }
defaults.set(normalized, forKey: Self.activeHubDefaultsKey)
return true
}
/// Unregisters a hub. When it was active, the first remaining hub (if
/// any) becomes active. Credential cleanup is the caller's job.
public func remove(_ raw: String) {
guard let normalized = HubURLNormalization.normalize(raw) else { return }
lock.lock()
defer { lock.unlock() }
var hubs = storedHubs()
guard let index = hubs.firstIndex(of: normalized) else { return }
hubs.remove(at: index)
defaults.set(hubs, forKey: Self.hubsDefaultsKey)
if defaults.string(forKey: Self.activeHubDefaultsKey) == normalized {
if let next = hubs.first {
defaults.set(next, forKey: Self.activeHubDefaultsKey)
} else {
defaults.removeObject(forKey: Self.activeHubDefaultsKey)
}
}
}
private func storedHubs() -> [String] {
defaults.stringArray(forKey: Self.hubsDefaultsKey) ?? []
}
}