Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilAgentAttachmentDownloads.swift
T
buzzert 80748f764f
TestFlight / Build and upload (push) Successful in 1m48s
ios: add Agent attachments and shared transcript fade
2026-10-14 19:51:27 -07:00

63 lines
2.9 KiB
Swift

import Foundation
// At most two authenticated downloads per room. Cache only verified bytes, bounded to 16 MiB,
// and replace the entire actor when API configuration changes. No credential-bearing disk cache.
actor AgentAttachmentDownloads {
private var active = 0
private var invalidated = false
private var transfers: [UUID: Task<Data, any Error>] = [:]
private var waiters: [(UUID, CheckedContinuation<Void, any Error>)] = []
private var cache: [AgentAttachment: Data] = [:]
private var order: [AgentAttachment] = []
private var byteCount = 0
func data(for attachment: AgentAttachment, client: any AgentAPIClienting) async throws -> Data {
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
if let value = cache[attachment] { return value }
try await acquire()
defer { release() }
try Task.checkCancellation()
if let value = cache[attachment] { return value }
guard !invalidated else { throw CancellationError() }
let id = UUID()
let transfer = Task { try await client.agentDownload(attachment) }
transfers[id] = transfer
defer { transfers[id] = nil }
let value = try await withTaskCancellationHandler { try await transfer.value } onCancel: { transfer.cancel() }
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
while byteCount + value.count > 16 * 1024 * 1024, !order.isEmpty {
let oldest = order.removeFirst()
byteCount -= cache.removeValue(forKey: oldest)?.count ?? 0
}
if cache[attachment] == nil { cache[attachment] = value; order.append(attachment); byteCount += value.count }
return value
}
func invalidate() {
invalidated = true
transfers.values.forEach { $0.cancel() }
for waiter in waiters { waiter.1.resume(throwing: CancellationError()) }
waiters = []; cache = [:]; order = []; byteCount = 0
}
private func acquire() async throws {
guard !invalidated else { throw CancellationError() }
if active < 2 { active += 1; return }
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
if Task.isCancelled { continuation.resume(throwing: CancellationError()) }
else { waiters.append((id, continuation)) }
}
} onCancel: { Task { await self.cancel(id) } }
}
private func cancel(_ id: UUID) {
guard let index = waiters.firstIndex(where: { $0.0 == id }) else { return }
waiters.remove(at: index).1.resume(throwing: CancellationError())
}
private func release() {
if waiters.isEmpty { active -= 1 }
else { waiters.removeFirst().1.resume() }
}
}