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
145 lines
5.4 KiB
Swift
145 lines
5.4 KiB
Swift
import Foundation
|
|
#if canImport(Security)
|
|
import Security
|
|
#endif
|
|
|
|
/// The per-hub credential record (`docs/api/client-contract/auth.md`).
|
|
///
|
|
/// The access token is the durable secret; the JWT is a 4-hour cache
|
|
/// persisted only to save one `/api/auth` round-trip at cold start.
|
|
public struct HubCredentials: Codable, Equatable, Sendable {
|
|
/// Normalized hub origin (see ``HubURLNormalization``); also the storage key.
|
|
public var hubUrl: String
|
|
/// Long-lived pairing secret, passed verbatim to `POST /api/auth`.
|
|
public var accessToken: String
|
|
/// Last issued JWT, if any.
|
|
public var jwt: String?
|
|
/// When `jwt` was obtained, epoch ms.
|
|
public var jwtObtainedAt: Int?
|
|
|
|
public init(hubUrl: String, accessToken: String, jwt: String? = nil, jwtObtainedAt: Int? = nil) {
|
|
self.hubUrl = hubUrl
|
|
self.accessToken = accessToken
|
|
self.jwt = jwt
|
|
self.jwtObtainedAt = jwtObtainedAt
|
|
}
|
|
}
|
|
|
|
/// Storage seam so `AuthManager` and the pairing flow are testable without
|
|
/// touching the real Keychain.
|
|
public protocol CredentialStoring: Sendable {
|
|
/// Returns the record for a normalized hub origin, or `nil` when unpaired.
|
|
func credentials(forHub hubUrl: String) throws -> HubCredentials?
|
|
/// Inserts or replaces the record keyed by `credentials.hubUrl`.
|
|
func store(_ credentials: HubCredentials) throws
|
|
/// Removes the record; deleting a non-existent record is not an error.
|
|
func deleteCredentials(forHub hubUrl: String) throws
|
|
}
|
|
|
|
// The Keychain-backed implementation is Darwin-only (Security framework).
|
|
// On Linux the `CredentialStoring` seam keeps everything testable via
|
|
// `InMemoryCredentialStore`; there is no production credential store there.
|
|
#if canImport(Security)
|
|
/// A Keychain operation failed with the given `SecItem` status.
|
|
public struct KeychainError: Error, Equatable, Sendable {
|
|
public let status: OSStatus
|
|
|
|
public init(status: OSStatus) {
|
|
self.status = status
|
|
}
|
|
}
|
|
|
|
/// Keychain-backed credential store: one generic-password item per hub under
|
|
/// service `run.hapi.companion`, account = normalized hub origin, value =
|
|
/// JSON-encoded ``HubCredentials``.
|
|
///
|
|
/// Items use `kSecAttrAccessibleAfterFirstUnlock` so a background refresh
|
|
/// after reboot (pre-unlock) fails gracefully instead of silently losing
|
|
/// credentials, and the data-protection keychain so behavior matches across
|
|
/// iOS and macOS (CI).
|
|
public struct KeychainCredentialStore: CredentialStoring {
|
|
public static let service = "run.hapi.companion"
|
|
|
|
public init() {}
|
|
|
|
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
|
|
var query = Self.baseQuery(account: hubUrl)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
switch status {
|
|
case errSecSuccess:
|
|
guard let data = item as? Data else {
|
|
throw KeychainError(status: errSecInternalError)
|
|
}
|
|
return try JSONDecoder().decode(HubCredentials.self, from: data)
|
|
case errSecItemNotFound:
|
|
return nil
|
|
default:
|
|
throw KeychainError(status: status)
|
|
}
|
|
}
|
|
|
|
public func store(_ credentials: HubCredentials) throws {
|
|
let data = try JSONEncoder().encode(credentials)
|
|
let query = Self.baseQuery(account: credentials.hubUrl)
|
|
let update: [String: Any] = [kSecValueData as String: data]
|
|
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
|
if updateStatus == errSecSuccess { return }
|
|
guard updateStatus == errSecItemNotFound else {
|
|
throw KeychainError(status: updateStatus)
|
|
}
|
|
var add = query
|
|
add[kSecValueData as String] = data
|
|
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
|
guard addStatus == errSecSuccess else {
|
|
throw KeychainError(status: addStatus)
|
|
}
|
|
}
|
|
|
|
public func deleteCredentials(forHub hubUrl: String) throws {
|
|
let status = SecItemDelete(Self.baseQuery(account: hubUrl) as CFDictionary)
|
|
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
throw KeychainError(status: status)
|
|
}
|
|
}
|
|
|
|
private static func baseQuery(account: String) -> [String: Any] {
|
|
[
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
kSecUseDataProtectionKeychain as String: true,
|
|
]
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// Dictionary-backed test double (also handy for previews).
|
|
public final class InMemoryCredentialStore: CredentialStoring, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var records: [String: HubCredentials] = [:]
|
|
|
|
public init() {}
|
|
|
|
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return records[hubUrl]
|
|
}
|
|
|
|
public func store(_ credentials: HubCredentials) throws {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
records[credentials.hubUrl] = credentials
|
|
}
|
|
|
|
public func deleteCredentials(forHub hubUrl: String) throws {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
records[hubUrl] = nil
|
|
}
|
|
}
|