ci / rust-arm64 (pull_request) Successful in 1m36s
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m38s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 1m12s
ci / rust (pull_request) Successful in 13m3s
Moving the management API onto Network.framework left one request per connection, so a library grid paid a TLS handshake per poster where the pooled URLSession had shared one. And the Apple client -- unlike Windows -- never cached art at all, so it re-fetched every poster on every visit. ArtCache: a size- and age-bounded blob cache in the CACHES directory (every byte is re-derivable from the host, so the system is welcome to evict it). Keyed by the SHA-256 of the absolute URL, so host-proxy paths and store CDN URLs share one cache without colliding. Reads touch the entry, so eviction is by last USE, not last write. Empty bodies and data: URLs are refused -- neither is worth a file. Defaults: 128 MB, 30 days. Connection pooling: MgmtConnectionPool keeps up to four keep-alive connections per host and makes further callers wait rather than opening more, which is the part that matters -- a grid can ask for dozens of posters at once. A connection the host dropped since we last used it is indistinguishable from a live one until we write, so a REUSED connection that fails is retried once on a fresh one; a fresh failure is a real failure. Keep-alive means a response can no longer be delimited by the peer hanging up, so HTTPResponseParser.messageLength finds the end from the framing itself -- Content-Length or the chunked terminal chunk plus trailers. Getting that wrong would truncate a response or bleed one into the next, silently, so it carries the bulk of the new tests. A connection with bytes left over after a response is dropped rather than reused: we never pipeline, so anything trailing means we are out of sync. LibraryView closes the loader's pooled connections on disappear instead of leaving sockets open on a screen the user has left. 16 new tests: message framing (both encodings, partial reads, back-to-back responses, close detection) and the cache (binary round trip, key separation, refusals, expiry, LRU eviction).
117 lines
5.0 KiB
Swift
117 lines
5.0 KiB
Swift
// On-disk cache for library cover art.
|
||
//
|
||
// Posters are the bulk of what the library screen transfers and they essentially never change, so
|
||
// re-fetching them on every visit is pure waste — the Windows client has cached them on disk for
|
||
// this reason and Apple did not. It matters more now that host art rides `MgmtTransport`: a cache
|
||
// hit costs no connection at all.
|
||
//
|
||
// Lives in the CACHES directory on purpose: every byte here is re-derivable from the host, so the
|
||
// system is welcome to evict it under storage pressure. Entries are keyed by the SHA-256 of the
|
||
// absolute URL, which covers both host-proxy paths and store CDN URLs without either colliding.
|
||
//
|
||
// Deliberately free of any Network.framework / PunktfunkCore dependency, so it can be unit-tested
|
||
// against a temporary directory.
|
||
|
||
import CryptoKit
|
||
import Foundation
|
||
|
||
/// A size- and age-bounded blob cache. An actor so disk work stays off whichever thread the
|
||
/// SwiftUI poster view happens to be on, and so pruning can never race a write.
|
||
actor ArtCache {
|
||
private let directory: URL
|
||
private let maxBytes: Int
|
||
private let maxAge: TimeInterval
|
||
private let fileManager = FileManager.default
|
||
|
||
/// `directory` is created on demand. Defaults: 128 MB — a 200-title library of 600×900
|
||
/// capsules lands far under that — and 30 days, which only matters for art a host later
|
||
/// replaces.
|
||
init(directory: URL, maxBytes: Int = 128 * 1024 * 1024, maxAge: TimeInterval = 30 * 24 * 3600) {
|
||
self.directory = directory
|
||
self.maxBytes = maxBytes
|
||
self.maxAge = maxAge
|
||
}
|
||
|
||
/// The app's standard location, or nil if the caches directory is unavailable (in which case
|
||
/// callers simply run without a cache rather than failing).
|
||
static func standard() -> ArtCache? {
|
||
guard let caches = FileManager.default.urls(
|
||
for: .cachesDirectory, in: .userDomainMask).first
|
||
else { return nil }
|
||
return ArtCache(directory: caches.appendingPathComponent("PunktfunkArt", isDirectory: true))
|
||
}
|
||
|
||
func data(for url: URL) -> Data? {
|
||
let file = path(for: url)
|
||
guard let data = try? Data(contentsOf: file) else { return nil }
|
||
// Age out stale art rather than serving it forever.
|
||
if let modified = modificationDate(of: file), Date().timeIntervalSince(modified) > maxAge {
|
||
try? fileManager.removeItem(at: file)
|
||
return nil
|
||
}
|
||
// Touch, so eviction can order by last USE rather than last write.
|
||
try? fileManager.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
|
||
return data
|
||
}
|
||
|
||
func store(_ data: Data, for url: URL) {
|
||
// An empty body is not art, and a `data:` URL is already inline — caching either is a
|
||
// pure loss.
|
||
guard !data.isEmpty, url.scheme?.lowercased() != "data" else { return }
|
||
do {
|
||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
try data.write(to: path(for: url), options: .atomic)
|
||
} catch {
|
||
return // a cache that can't write is a slow cache, not a broken app
|
||
}
|
||
prune()
|
||
}
|
||
|
||
/// Drop the oldest entries until the directory fits the budget. Also removes anything past
|
||
/// `maxAge` so a cache that is under budget still doesn't hoard stale art forever.
|
||
func prune() {
|
||
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
|
||
guard let entries = try? fileManager.contentsOfDirectory(
|
||
at: directory, includingPropertiesForKeys: keys, options: .skipsHiddenFiles)
|
||
else { return }
|
||
|
||
var files: [(url: URL, date: Date, size: Int)] = []
|
||
var total = 0
|
||
let now = Date()
|
||
for entry in entries {
|
||
let values = try? entry.resourceValues(forKeys: Set(keys))
|
||
let date = values?.contentModificationDate ?? .distantPast
|
||
let size = values?.fileSize ?? 0
|
||
if now.timeIntervalSince(date) > maxAge {
|
||
try? fileManager.removeItem(at: entry)
|
||
continue
|
||
}
|
||
files.append((entry, date, size))
|
||
total += size
|
||
}
|
||
guard total > maxBytes else { return }
|
||
|
||
// Oldest first — `data(for:)` touches on read, so this is least-recently-USED.
|
||
for file in files.sorted(by: { $0.date < $1.date }) {
|
||
guard total > maxBytes else { break }
|
||
try? fileManager.removeItem(at: file.url)
|
||
total -= file.size
|
||
}
|
||
}
|
||
|
||
/// Wipe the cache — for a "clear cached data" affordance, and for tests.
|
||
func clear() {
|
||
try? fileManager.removeItem(at: directory)
|
||
}
|
||
|
||
private func path(for url: URL) -> URL {
|
||
let digest = SHA256.hash(data: Data(url.absoluteString.utf8))
|
||
let name = digest.map { String(format: "%02x", $0) }.joined()
|
||
return directory.appendingPathComponent(name, isDirectory: false)
|
||
}
|
||
|
||
private func modificationDate(of file: URL) -> Date? {
|
||
(try? file.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
|
||
}
|
||
}
|