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
124 lines
5.3 KiB
Swift
124 lines
5.3 KiB
Swift
#if canImport(CryptoKit)
|
|
import CryptoKit
|
|
#endif
|
|
import Foundation
|
|
import HapiProtocol
|
|
|
|
/// Per-session message-window snapshots on disk: one JSON file per session in
|
|
/// ``directory``, written atomically, pruned LRU to ``maxSessions`` files.
|
|
///
|
|
/// Mirrors the web's `sessionStorage` persistence of
|
|
/// `PersistedMessageWindowState` (key `hapi:message-window:v2:`) and the
|
|
/// Android port's `WindowSnapshots`: messages + cursors + epoch for instant
|
|
/// cold-start rendering; `MessageWindowLogic.hydrate` restores interrupted
|
|
/// send states and flags stale snapshots for a latest reset.
|
|
///
|
|
/// All operations are best-effort and synchronous (files are small; the web
|
|
/// writes to `sessionStorage` synchronously too) — I/O errors are swallowed
|
|
/// like the web's `persistState` try/catch.
|
|
///
|
|
/// TODO(A-M2a dedup): the session-list work package introduces a generic
|
|
/// JSON snapshot cache; fold this store into it once both are on the branch
|
|
/// (Android carries the same TODO between `WindowSnapshots` and
|
|
/// `JsonSnapshotStore`).
|
|
public struct WindowSnapshotStore: Sendable {
|
|
public let directory: URL
|
|
public let maxSessions: Int
|
|
|
|
private static let suffix = ".window.json"
|
|
|
|
public init(directory: URL, maxSessions: Int = 10) {
|
|
self.directory = directory
|
|
self.maxSessions = maxSessions
|
|
}
|
|
|
|
/// Default location: `Caches/hapi-message-windows` (purgeable by the OS —
|
|
/// snapshots are a warm-start optimization, never authoritative).
|
|
public static func defaultStore() -> WindowSnapshotStore {
|
|
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.temporaryDirectory
|
|
return WindowSnapshotStore(directory: caches.appendingPathComponent("hapi-message-windows"))
|
|
}
|
|
|
|
public func save(sessionId: String, snapshot: PersistedMessageWindow) {
|
|
do {
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let data = try HapiJSON.encoder.encode(snapshot)
|
|
try data.write(to: fileURL(for: sessionId), options: .atomic)
|
|
prune()
|
|
} catch {
|
|
// Best-effort persistence, mirroring the web store.
|
|
}
|
|
}
|
|
|
|
public func load(sessionId: String) -> PersistedMessageWindow? {
|
|
let url = fileURL(for: sessionId)
|
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
guard let snapshot = try? HapiJSON.decoder.decode(PersistedMessageWindow.self, from: data) else {
|
|
try? FileManager.default.removeItem(at: url)
|
|
return nil
|
|
}
|
|
// Touch for LRU recency: reading a snapshot marks the session live.
|
|
try? FileManager.default.setAttributes(
|
|
[.modificationDate: Date()],
|
|
ofItemAtPath: url.path
|
|
)
|
|
return snapshot
|
|
}
|
|
|
|
public func delete(sessionId: String) {
|
|
try? FileManager.default.removeItem(at: fileURL(for: sessionId))
|
|
}
|
|
|
|
/// Drop the least-recently-written files beyond ``maxSessions``.
|
|
private func prune() {
|
|
guard let contents = try? FileManager.default.contentsOfDirectory(
|
|
at: directory,
|
|
includingPropertiesForKeys: [.contentModificationDateKey]
|
|
) else { return }
|
|
let snapshots = contents.filter { $0.lastPathComponent.hasSuffix(Self.suffix) }
|
|
guard snapshots.count > maxSessions else { return }
|
|
func modificationDate(_ url: URL) -> Date {
|
|
(try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate)
|
|
?? .distantPast
|
|
}
|
|
for stale in snapshots.sorted(by: { modificationDate($0) > modificationDate($1) }).dropFirst(maxSessions) {
|
|
try? FileManager.default.removeItem(at: stale)
|
|
}
|
|
}
|
|
|
|
/// Session ids are arbitrary strings — file names come from a digest
|
|
/// (same scheme as the Android port: first 32 hex chars of SHA-256).
|
|
private func fileURL(for sessionId: String) -> URL {
|
|
directory.appendingPathComponent(Self.digestName(for: sessionId) + Self.suffix)
|
|
}
|
|
|
|
#if canImport(CryptoKit)
|
|
static func digestName(for sessionId: String) -> String {
|
|
let digest = SHA256.hash(data: Data(sessionId.utf8))
|
|
return String(digest.map { String(format: "%02x", $0) }.joined().prefix(32))
|
|
}
|
|
#else
|
|
/// Linux fallback (no CryptoKit, and no new deps allowed): FNV-1a 64-bit
|
|
/// over the UTF-8 bytes, hex-encoded. The digest is only a filename — it
|
|
/// never crosses devices or the wire, so the two platforms hashing
|
|
/// differently is harmless; collisions are the only concern and FNV-1a
|
|
/// is adequate for tens of session ids.
|
|
static func digestName(for sessionId: String) -> String {
|
|
var hash: UInt64 = 0xcbf2_9ce4_8422_2325
|
|
for byte in sessionId.utf8 {
|
|
hash ^= UInt64(byte)
|
|
hash = hash &* 0x0000_0100_0000_01B3
|
|
}
|
|
// A second pass seeded differently widens the name to 32 hex chars so
|
|
// both platforms produce equal-length filenames.
|
|
var hash2: UInt64 = 0x84222325_cbf29ce4
|
|
for byte in sessionId.utf8.reversed() {
|
|
hash2 ^= UInt64(byte)
|
|
hash2 = hash2 &* 0x0000_0100_0000_01B3
|
|
}
|
|
return String(format: "%016lx%016lx", hash, hash2)
|
|
}
|
|
#endif
|
|
}
|