Merge pull request 'Worktree apple mgmt ats bypass' (#103) from worktree-apple-mgmt-ats-bypass into main
apple / swift (push) Successful in 1m39s
ci / rust-arm64 (push) Successful in 2m3s
ci / web (push) Successful in 1m4s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
ci / docs-site (push) Successful in 1m58s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 49s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 3s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Successful in 9m42s
apple / swift (push) Successful in 1m39s
ci / rust-arm64 (push) Successful in 2m3s
ci / web (push) Successful in 1m4s
ci / bun-nix (push) Successful in 31s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
ci / docs-site (push) Successful in 1m58s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 39s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 49s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 3s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Successful in 9m42s
Reviewed-on: #103
This commit was merged in pull request #103.
This commit is contained in:
@@ -19,6 +19,14 @@
|
||||
<array>
|
||||
<string>_punktfunk._udp</string>
|
||||
</array>
|
||||
<!-- NOTE: there is deliberately NO NSAppTransportSecurity dict here. ATS stays fully ON.
|
||||
The host is self-signed at a user-supplied address, which default ATS can never accept
|
||||
(it exempts only .local, unqualified names, and RFC1918/link-local literals — notably NOT
|
||||
Tailscale's 100.64/10 CGNAT range), so the management API talks over MgmtTransport
|
||||
(Network.framework), which is outside the URL loading system and pins the host by
|
||||
SHA-256 fingerprint instead. That leaves cover-art CDN fetches as the app's only
|
||||
URLSession traffic, and they keep the full ATS policy — which is the whole reason not to
|
||||
reach for NSAllowsArbitraryLoads here. See MgmtTransport.swift. -->
|
||||
<!-- Background keep-alive (opt-in, iOS/iPadOS): the ONLY sanctioned way to keep the long-lived
|
||||
QUIC socket + pump-thread set alive while backgrounded is the audio background mode, backed
|
||||
by the session's real, audible remote audio (AVAudioEngine keeps rendering). Video decode is
|
||||
|
||||
@@ -21,7 +21,7 @@ import GameController
|
||||
struct LibraryCoverflowView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
let games: [GameEntry]
|
||||
let imageSession: URLSession?
|
||||
let artLoader: LibraryArtLoader?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Button B (back) — dismisses the library screen. No touch equivalent needed here (the toolbar
|
||||
/// Close button already covers that); this is what makes gamepad-only exit possible.
|
||||
@@ -124,7 +124,7 @@ struct LibraryCoverflowView: View {
|
||||
_ game: GameEntry, width: CGFloat, height: CGFloat, entrance: CardEntrance
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, session: imageSession,
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
|
||||
@@ -24,10 +24,9 @@ struct LibraryView: View {
|
||||
@State private var games: [GameEntry] = []
|
||||
@State private var loading = false
|
||||
@State private var errorText: String?
|
||||
/// Authenticated session for cover-art fetches (the same paired identity + host pinning as the
|
||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||
@State private var imageSession: URLSession?
|
||||
/// Cover-art loader (the same paired identity + host pinning as the list fetch, reused across
|
||||
/// every poster in the grid). Built alongside `games` in `load()`; dropped on disappear.
|
||||
@State private var artLoader: LibraryArtLoader?
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||
@@ -62,8 +61,11 @@ struct LibraryView: View {
|
||||
}
|
||||
.task { await load() }
|
||||
.onDisappear {
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = nil
|
||||
// Hand the loader off before clearing it, so its pooled connections are closed
|
||||
// rather than left open on a screen the user has left.
|
||||
let leaving = artLoader
|
||||
artLoader = nil
|
||||
Task { await leaving?.close() }
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// B closes the library even before the coverflow exists (loading / error / empty):
|
||||
@@ -89,7 +91,7 @@ struct LibraryView: View {
|
||||
} else {
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||
games: games, artLoader: artLoader, onLaunch: onLaunch,
|
||||
onDismiss: { (onClose ?? { dismiss() })() },
|
||||
controllerActive: controllerActive)
|
||||
} else {
|
||||
@@ -124,10 +126,10 @@ struct LibraryView: View {
|
||||
LazyVGrid(columns: columns, spacing: 18) {
|
||||
ForEach(entries) { game in
|
||||
if let onLaunch {
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) }
|
||||
Button { onLaunch(game.id) } label: { GameCard(game: game, artLoader: artLoader) }
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
GameCard(game: game, imageSession: imageSession)
|
||||
GameCard(game: game, artLoader: artLoader)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,8 +208,7 @@ struct LibraryView: View {
|
||||
keyPEM: identity.keyPEM,
|
||||
hostFingerprint: current.pinnedSHA256
|
||||
).launchersFirst
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = try LibraryImageLoader.session(
|
||||
artLoader = try LibraryArtLoader(
|
||||
address: current.address,
|
||||
port: current.effectiveMgmtPort,
|
||||
certPEM: identity.certPEM,
|
||||
@@ -249,11 +250,11 @@ private struct LibraryBackCatcher: View {
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
let game: GameEntry
|
||||
let imageSession: URLSession?
|
||||
let artLoader: LibraryArtLoader?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession)
|
||||
PosterImage(candidates: game.art.posterCandidates, title: game.title, loader: artLoader)
|
||||
.aspectRatio(2.0 / 3.0, contentMode: .fit)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
|
||||
@@ -60,8 +60,8 @@ private extension Image {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to
|
||||
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryArtLoader`), advancing past any that fail to
|
||||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||
/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback
|
||||
/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report
|
||||
@@ -70,7 +70,7 @@ private extension Image {
|
||||
struct PosterImage: View {
|
||||
let candidates: [URL]
|
||||
let title: String
|
||||
let session: URLSession?
|
||||
let loader: LibraryArtLoader?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
@@ -108,7 +108,7 @@ struct PosterImage: View {
|
||||
onLoaded?()
|
||||
return
|
||||
}
|
||||
guard let session, let data = try? await session.data(from: candidates[index]).0,
|
||||
guard let loader, let data = try? await loader.data(for: candidates[index]),
|
||||
let loaded = PlatformImage(data: data)
|
||||
else {
|
||||
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,6 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
|
||||
private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-tls")
|
||||
|
||||
enum ClientTLS {
|
||||
enum TLSError: LocalizedError {
|
||||
@@ -134,62 +131,8 @@ enum ClientTLS {
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client
|
||||
/// already trusts) and presents the client identity for the mTLS client-cert challenge — but ONLY
|
||||
/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this
|
||||
/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a
|
||||
/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust
|
||||
/// evaluation and no client cert — exactly what `URLSession.shared` would have done. Without the
|
||||
/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches
|
||||
/// the host's) and the client identity would leak to servers that didn't ask for it.
|
||||
final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
|
||||
private let identity: SecIdentity
|
||||
private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU)
|
||||
private let host: String
|
||||
private let port: Int
|
||||
|
||||
init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) {
|
||||
self.identity = identity
|
||||
self.pinnedHostFingerprint = pinnedHostFingerprint
|
||||
self.host = host
|
||||
self.port = Int(port)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
let space = challenge.protectionSpace
|
||||
guard space.host == host, space.port == port else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
switch space.authenticationMethod {
|
||||
case NSURLAuthenticationMethodServerTrust:
|
||||
// Pin the host cert by fingerprint — the host is self-signed (the client trusts it the
|
||||
// same way the QUIC session does). No pin yet (TOFU) → accept the presented leaf.
|
||||
guard let trust = space.serverTrust,
|
||||
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
|
||||
else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
let der = SecCertificateCopyData(leaf) as Data
|
||||
let fp = Data(SHA256.hash(data: der))
|
||||
if let pinned = pinnedHostFingerprint, pinned != fp {
|
||||
tlsLog.warning("library: host cert fingerprint mismatch — refusing")
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
return
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
|
||||
case NSURLAuthenticationMethodClientCertificate:
|
||||
completionHandler(.useCredential,
|
||||
URLCredential(identity: identity, certificates: nil, persistence: .forSession))
|
||||
|
||||
default:
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The URLSession pinning delegate that used to live here is gone: the management API now speaks
|
||||
// over `MgmtTransport` (Network.framework), which states the same trust rule in a
|
||||
// `sec_protocol_options_set_verify_block` and — unlike the URL loading system — is not subject to
|
||||
// App Transport Security. That is what lets ATS stay ON for the cover-art CDN fetches, which are
|
||||
// the only URLSession traffic left in the app. See MgmtTransport.swift for the full rationale.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Minimal HTTP/1.1 response parsing for `MgmtTransport`.
|
||||
//
|
||||
// We speak HTTP ourselves because the management API has to be reached OUTSIDE the URL loading
|
||||
// system: App Transport Security applies to URLSession and cannot be relaxed for the arbitrary,
|
||||
// user-supplied addresses a punktfunk host lives at (see MgmtTransport for the full story). What
|
||||
// we need is GETs against one host, so this covers exactly that and nothing more — no redirects,
|
||||
// no request bodies, no content negotiation.
|
||||
//
|
||||
// It does need to find where a response ENDS without waiting for the peer to hang up, because the
|
||||
// connection is reused across a grid's worth of poster fetches (`messageLength`). Both framings
|
||||
// hyper emits are handled: `Content-Length`, and `Transfer-Encoding: chunked` for the art proxy.
|
||||
//
|
||||
// Deliberately free of any Network.framework / PunktfunkCore dependency: pure bytes-in,
|
||||
// value-out, so it can be unit-tested (and typechecked) on its own.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A parsed HTTP/1.1 response. `headers` keys are lowercased, so lookups are case-insensitive the
|
||||
/// way the grammar requires.
|
||||
struct HTTPResponse: Sendable {
|
||||
let status: Int
|
||||
let headers: [String: String]
|
||||
let body: Data
|
||||
|
||||
func header(_ name: String) -> String? { headers[name.lowercased()] }
|
||||
|
||||
/// Did the peer ask to close? HTTP/1.1 keeps the connection open unless it says otherwise.
|
||||
var wantsClose: Bool {
|
||||
header("connection")?.lowercased().contains("close") ?? false
|
||||
}
|
||||
}
|
||||
|
||||
enum HTTPParseError: Error, Sendable {
|
||||
/// The header block never terminated, or the body is shorter than `Content-Length` promised —
|
||||
/// i.e. the peer hung up mid-response. Never surface a truncated body as success: a clipped
|
||||
/// JSON array would read as "this host has no games" rather than as the failure it is.
|
||||
case truncated
|
||||
case malformedStatusLine
|
||||
case malformedHeader
|
||||
case malformedChunk
|
||||
}
|
||||
|
||||
enum HTTPResponseParser {
|
||||
/// Byte length of the first complete response in `raw`, or nil when more bytes are needed.
|
||||
///
|
||||
/// Also nil when the response carries no framing header at all, since then the body runs
|
||||
/// until the peer closes and has no knowable length — a connection that answers that way
|
||||
/// cannot be reused.
|
||||
static func messageLength(in raw: Data) throws -> Int? {
|
||||
let b = [UInt8](raw)
|
||||
guard let head = try parseHead(b) else { return nil }
|
||||
if head.headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
||||
return try chunkedEnd(b, from: head.bodyStart)
|
||||
}
|
||||
if let field = head.headers["content-length"] {
|
||||
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
||||
throw HTTPParseError.malformedHeader
|
||||
}
|
||||
let end = head.bodyStart + length
|
||||
return b.count >= end ? end : nil
|
||||
}
|
||||
return nil // framed by connection close
|
||||
}
|
||||
|
||||
/// Parse one complete response. `raw` must hold exactly one message (use `messageLength` to
|
||||
/// slice it) or, for a close-framed response, everything read up to EOF.
|
||||
static func parse(_ raw: Data) throws -> HTTPResponse {
|
||||
let b = [UInt8](raw)
|
||||
guard let head = try parseHead(b) else { throw HTTPParseError.truncated }
|
||||
let rest = Data(b[head.bodyStart...])
|
||||
let body: Data
|
||||
if head.headers["transfer-encoding"]?.lowercased().contains("chunked") == true {
|
||||
body = try decodeChunked(rest)
|
||||
} else if let field = head.headers["content-length"] {
|
||||
guard let length = Int(field.trimmingCharacters(in: .whitespaces)), length >= 0 else {
|
||||
throw HTTPParseError.malformedHeader
|
||||
}
|
||||
guard rest.count >= length else { throw HTTPParseError.truncated }
|
||||
body = rest.prefix(length)
|
||||
} else {
|
||||
body = rest // framed by connection close: what we read is what there is
|
||||
}
|
||||
return HTTPResponse(status: head.status, headers: head.headers, body: body)
|
||||
}
|
||||
|
||||
private struct Head {
|
||||
let status: Int
|
||||
let headers: [String: String]
|
||||
/// Offset of the first body byte (just past the CRLFCRLF).
|
||||
let bodyStart: Int
|
||||
}
|
||||
|
||||
/// Status line + header block, or nil if the block hasn't fully arrived.
|
||||
private static func parseHead(_ b: [UInt8]) throws -> Head? {
|
||||
guard let headEnd = findHeaderEnd(b) else { return nil }
|
||||
let text = String(decoding: b[0..<headEnd], as: UTF8.self)
|
||||
var lines = text.components(separatedBy: "\r\n")
|
||||
guard !lines.isEmpty else { throw HTTPParseError.malformedStatusLine }
|
||||
|
||||
// "HTTP/1.1 200 OK" — the reason phrase is optional and ignored.
|
||||
let statusLine = lines.removeFirst().split(separator: " ", maxSplits: 2,
|
||||
omittingEmptySubsequences: false)
|
||||
guard statusLine.count >= 2, statusLine[0].hasPrefix("HTTP/"),
|
||||
let status = Int(statusLine[1])
|
||||
else { throw HTTPParseError.malformedStatusLine }
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
for line in lines where !line.isEmpty {
|
||||
// A leading space/tab marks an obsolete folded continuation line. Nothing we talk to
|
||||
// emits them, and silently mis-parsing one as a field is worse than refusing it.
|
||||
guard !line.hasPrefix(" "), !line.hasPrefix("\t"),
|
||||
let colon = line.firstIndex(of: ":")
|
||||
else { throw HTTPParseError.malformedHeader }
|
||||
let name = String(line[line.startIndex..<colon]).lowercased()
|
||||
let value = String(line[line.index(after: colon)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
// Repeated fields join with ", " per RFC 9110; none of ours repeat, but dropping one
|
||||
// silently would be a lie.
|
||||
headers[name] = headers[name].map { "\($0), \(value)" } ?? value
|
||||
}
|
||||
return Head(status: status, headers: headers, bodyStart: headEnd + 4)
|
||||
}
|
||||
|
||||
/// Index just past the CRLFCRLF that ends the header block.
|
||||
private static func findHeaderEnd(_ b: [UInt8]) -> Int? {
|
||||
guard b.count >= 4 else { return nil }
|
||||
for i in 0...(b.count - 4) where b[i] == 0x0D && b[i + 1] == 0x0A
|
||||
&& b[i + 2] == 0x0D && b[i + 3] == 0x0A {
|
||||
return i
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Offset just past a complete chunked body (terminal chunk plus any trailers), or nil if it
|
||||
/// hasn't all arrived.
|
||||
private static func chunkedEnd(_ b: [UInt8], from start: Int) throws -> Int? {
|
||||
var i = start
|
||||
while true {
|
||||
guard let lineEnd = findCRLF(b, from: i) else { return nil }
|
||||
guard let size = chunkSize(b, i, lineEnd) else { throw HTTPParseError.malformedChunk }
|
||||
i = lineEnd + 2
|
||||
if size == 0 {
|
||||
// Terminal chunk. Trailers (if any) run to the next empty line.
|
||||
var j = i
|
||||
while true {
|
||||
guard let end = findCRLF(b, from: j) else { return nil }
|
||||
if end == j { return j + 2 }
|
||||
j = end + 2
|
||||
}
|
||||
}
|
||||
guard i + size + 2 <= b.count else { return nil }
|
||||
i += size + 2 // payload plus its trailing CRLF
|
||||
}
|
||||
}
|
||||
|
||||
/// `Transfer-Encoding: chunked` decoding. hyper streams the art proxy this way, so this is a
|
||||
/// live path, not defensive dead code.
|
||||
static func decodeChunked(_ data: Data) throws -> Data {
|
||||
let b = [UInt8](data)
|
||||
var i = 0
|
||||
var out = Data()
|
||||
while true {
|
||||
guard let lineEnd = findCRLF(b, from: i) else { throw HTTPParseError.malformedChunk }
|
||||
guard let size = chunkSize(b, i, lineEnd) else { throw HTTPParseError.malformedChunk }
|
||||
i = lineEnd + 2
|
||||
if size == 0 { return out } // terminal chunk; trailers are ignored
|
||||
guard i + size <= b.count else { throw HTTPParseError.malformedChunk }
|
||||
out.append(contentsOf: b[i..<(i + size)])
|
||||
i += size
|
||||
guard i + 1 < b.count, b[i] == 0x0D, b[i + 1] == 0x0A else {
|
||||
throw HTTPParseError.malformedChunk
|
||||
}
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
|
||||
/// "1a" or "1a;ext=value" → 26. Nil if it isn't a hex size.
|
||||
private static func chunkSize(_ b: [UInt8], _ from: Int, _ to: Int) -> Int? {
|
||||
let field = String(decoding: b[from..<to], as: UTF8.self)
|
||||
.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: false)[0]
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard let size = Int(field, radix: 16), size >= 0 else { return nil }
|
||||
return size
|
||||
}
|
||||
|
||||
private static func findCRLF(_ b: [UInt8], from: Int) -> Int? {
|
||||
guard from >= 0, b.count >= 2 else { return nil }
|
||||
var i = from
|
||||
while i + 1 < b.count {
|
||||
if b[i] == 0x0D && b[i + 1] == 0x0A { return i }
|
||||
i += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,10 @@ public extension Array where Element == GameEntry {
|
||||
/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet").
|
||||
public enum LibraryError: LocalizedError {
|
||||
case unauthorized
|
||||
/// The host's certificate didn't hash to the fingerprint pinned at pairing — an impostor, or
|
||||
/// a host reinstalled/re-keyed since. Distinct from `unreachable` because the remedy is
|
||||
/// completely different: re-pair, don't go hunting the network.
|
||||
case pinMismatch
|
||||
case http(Int)
|
||||
case unreachable(String)
|
||||
|
||||
@@ -91,12 +95,22 @@ public enum LibraryError: LocalizedError {
|
||||
case .unauthorized:
|
||||
return "The host didn't recognize this device. Pair with the host first — it "
|
||||
+ "authorizes paired clients by their certificate (no token needed)."
|
||||
case .pinMismatch:
|
||||
return "The host's certificate doesn't match the one this device paired with. "
|
||||
+ "If the host was reinstalled, forget it here and pair again."
|
||||
case .http(let code):
|
||||
return "The management API returned HTTP \(code)."
|
||||
case .unreachable(let why):
|
||||
return "Couldn't reach the host's management API: \(why). It binds the LAN by default, "
|
||||
+ "so check the host is updated and reachable (a host pinned to "
|
||||
+ "`--mgmt-bind 127.0.0.1` is loopback-only and can't be browsed remotely)."
|
||||
// The library rides a DIFFERENT port than the stream (the management API, 47990 by
|
||||
// default; the stream is QUIC on 9777), so it can fail while streaming to the same
|
||||
// host works perfectly — say that first, because the opposite assumption has sent
|
||||
// more than one person hunting the wrong layer. Opening that URL in a browser is the
|
||||
// fastest way to tell "port unreachable" apart from anything client-side.
|
||||
return "Couldn't reach the host's management API: \(why). The library uses a "
|
||||
+ "different port than the stream (47990 by default), so streaming can work "
|
||||
+ "while this doesn't. Check that port is reachable from this device, and that "
|
||||
+ "the host isn't pinned to `--mgmt-bind 127.0.0.1`, which serves it to the "
|
||||
+ "host itself only."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,47 +129,66 @@ public enum LibraryClient {
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data?
|
||||
) async throws -> [GameEntry] {
|
||||
guard let url = URL(string: "https://\(address):\(port)/api/v1/library") else {
|
||||
throw LibraryError.unreachable("invalid host address")
|
||||
}
|
||||
let identity: SecIdentity
|
||||
do {
|
||||
identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(
|
||||
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||||
}
|
||||
let delegate = LibraryTLSDelegate(
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
|
||||
let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
let req = URLRequest(url: url, timeoutInterval: 10)
|
||||
let (data, response): (Data, URLResponse)
|
||||
do {
|
||||
(data, response) = try await session.data(for: req)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(error.localizedDescription)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LibraryError.unreachable("not an HTTP response")
|
||||
}
|
||||
switch http.statusCode {
|
||||
guard let base = URL(string: "\(baseURL(address: address, port: port))/api/v1/library")
|
||||
else { throw LibraryError.unreachable("invalid host address") }
|
||||
let identity = try clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
let response = try await send(
|
||||
path: "/api/v1/library", address: address, port: port,
|
||||
identity: identity, hostFingerprint: hostFingerprint)
|
||||
switch response.status {
|
||||
case 200:
|
||||
var games = try JSONDecoder().decode([GameEntry].self, from: data)
|
||||
// Steam art now comes back as host-relative proxy paths (`/api/v1/library/art/...`,
|
||||
// see the host's `library::steam_art`) so they work the same regardless of which
|
||||
var games = try JSONDecoder().decode([GameEntry].self, from: response.body)
|
||||
// Steam art comes back as host-relative proxy paths (`/api/v1/library/art/...`, see
|
||||
// the host's `library::steam_art`) so they work the same regardless of which
|
||||
// interface/port the client reached the host on. Resolve them against THIS host now,
|
||||
// so every other consumer just sees ordinary absolute URLs.
|
||||
let base = url
|
||||
for i in games.indices {
|
||||
games[i].art = games[i].art.resolved(against: base)
|
||||
}
|
||||
return games
|
||||
case 401:
|
||||
// 403 joins 401 here: both are the host declining this certificate, and the remedy the
|
||||
// user needs is the same one.
|
||||
case 401, 403:
|
||||
throw LibraryError.unauthorized
|
||||
default:
|
||||
throw LibraryError.http(http.statusCode)
|
||||
throw LibraryError.http(response.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// `https://addr:port`, IPv6 literals bracketed — the mirror of the Rust client's `base_url`.
|
||||
static func baseURL(address: String, port: UInt16) -> String {
|
||||
let bare = address.hasPrefix("[") && address.hasSuffix("]")
|
||||
? String(address.dropFirst().dropLast()) : address
|
||||
return bare.contains(":") ? "https://[\(bare)]:\(port)" : "https://\(bare):\(port)"
|
||||
}
|
||||
|
||||
/// Build the paired identity, restating any keychain failure in the UI's vocabulary.
|
||||
static func clientIdentity(certPEM: String, keyPEM: String) throws -> SecIdentity {
|
||||
do {
|
||||
return try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
} catch {
|
||||
throw LibraryError.unreachable(
|
||||
(error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// One GET against the host, with transport failures mapped onto `LibraryError`.
|
||||
static func send(
|
||||
path: String, address: String, port: UInt16,
|
||||
identity: SecIdentity, hostFingerprint: Data?
|
||||
) async throws -> HTTPResponse {
|
||||
do {
|
||||
return try await MgmtTransport.get(
|
||||
host: address, port: port, path: path,
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint)
|
||||
} catch MgmtTransportError.pinMismatch {
|
||||
throw LibraryError.pinMismatch
|
||||
} catch MgmtTransportError.timedOut {
|
||||
throw LibraryError.unreachable("timed out")
|
||||
} catch let error as MgmtTransportError {
|
||||
throw LibraryError.unreachable(String(describing: error))
|
||||
} catch {
|
||||
throw LibraryError.unreachable(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,23 +212,77 @@ extension Artwork {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the authenticated `URLSession` the library UI uses to fetch cover-art images — the same
|
||||
/// paired identity + host pinning as [`LibraryClient.fetch`], reused across a whole grid's worth of
|
||||
/// poster loads (this session is NOT one-shot: callers own its lifetime and should invalidate it
|
||||
/// when the view goes away). Safe to use for every candidate URL a `GameEntry`'s `Artwork` carries:
|
||||
/// `LibraryTLSDelegate` only pins/presents-cert for the host itself, deferring to normal system
|
||||
/// trust + no client cert for any other origin (an external CDN URL).
|
||||
public enum LibraryImageLoader {
|
||||
public static func session(
|
||||
/// Loads cover art for the library UI, routing each URL to the transport that suits its origin.
|
||||
///
|
||||
/// A `GameEntry`'s art candidates mix two very different things: the host's own art proxy
|
||||
/// (`/api/v1/library/art/...`, resolved to absolute URLs against this host) and public store CDN
|
||||
/// URLs carried verbatim on custom/GOG/Heroic entries. Host URLs go over [`MgmtTransport`] with
|
||||
/// the paired identity and the pinned fingerprint — outside the URL loading system, so App
|
||||
/// Transport Security can stay ON app-wide. Every other origin keeps ordinary `URLSession` with
|
||||
/// full system trust evaluation and no client certificate, which is exactly what it should get.
|
||||
///
|
||||
/// Posters are cached on disk (`ArtCache`), so a second visit to a library costs no network at
|
||||
/// all — and the connections behind a first visit are pooled and kept alive rather than paying a
|
||||
/// TLS handshake per tile.
|
||||
///
|
||||
/// Built once per library screen and reused across a whole grid's worth of posters.
|
||||
public final class LibraryArtLoader: @unchecked Sendable {
|
||||
private let address: String
|
||||
private let port: UInt16
|
||||
private let identity: SecIdentity
|
||||
private let hostFingerprint: Data?
|
||||
/// Third-party origins only. No delegate: these are ordinary public HTTPS URLs and get the
|
||||
/// system's normal certificate validation.
|
||||
private let cdn = URLSession(configuration: .default)
|
||||
/// nil when the caches directory is unavailable — then we simply always fetch.
|
||||
private let cache = ArtCache.standard()
|
||||
|
||||
public init(
|
||||
address: String,
|
||||
port: UInt16 = punktfunkDefaultMgmtPort,
|
||||
certPEM: String,
|
||||
keyPEM: String,
|
||||
hostFingerprint: Data?
|
||||
) throws -> URLSession {
|
||||
let identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
let delegate = LibraryTLSDelegate(
|
||||
identity: identity, pinnedHostFingerprint: hostFingerprint, host: address, port: port)
|
||||
return URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
|
||||
) throws {
|
||||
self.address = address
|
||||
self.port = port
|
||||
self.identity = try LibraryClient.clientIdentity(certPEM: certPEM, keyPEM: keyPEM)
|
||||
self.hostFingerprint = hostFingerprint
|
||||
}
|
||||
|
||||
public func data(for url: URL) async throws -> Data {
|
||||
if let cache, let cached = await cache.data(for: url) { return cached }
|
||||
let fetched = try await fetch(url)
|
||||
if let cache { await cache.store(fetched, for: url) }
|
||||
return fetched
|
||||
}
|
||||
|
||||
/// Release this host's pooled connections — call when the library screen goes away, so we
|
||||
/// don't sit on open TLS sockets the user is finished with.
|
||||
public func close() async {
|
||||
await MgmtConnectionPool.shared.closeAll(
|
||||
matching: "\(MgmtTransport.unbracketed(address)):\(port):")
|
||||
}
|
||||
|
||||
private func fetch(_ url: URL) async throws -> Data {
|
||||
guard isHostOrigin(url) else { return try await cdn.data(from: url).0 }
|
||||
var path = url.path.isEmpty ? "/" : url.path
|
||||
if let query = url.query { path += "?\(query)" }
|
||||
let response = try await LibraryClient.send(
|
||||
path: path, address: address, port: port,
|
||||
identity: identity, hostFingerprint: hostFingerprint)
|
||||
guard response.status == 200 else { throw LibraryError.http(response.status) }
|
||||
return response.body
|
||||
}
|
||||
|
||||
/// Does this URL point at the host's own art proxy? Compared on host + port rather than a
|
||||
/// string prefix, so a differently-spelled but equivalent URL still takes the pinned path.
|
||||
private func isHostOrigin(_ url: URL) -> Bool {
|
||||
guard let host = url.host else { return false }
|
||||
let bare = address.hasPrefix("[") && address.hasSuffix("]")
|
||||
? String(address.dropFirst().dropLast()) : address
|
||||
let scheme = url.scheme?.lowercased()
|
||||
return host.caseInsensitiveCompare(bare) == .orderedSame
|
||||
&& (url.port ?? (scheme == "http" ? 80 : 443)) == Int(port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// HTTPS transport for the host's management REST API, built on Network.framework rather than
|
||||
// URLSession.
|
||||
//
|
||||
// WHY NOT URLSession. App Transport Security governs the URL loading system, and its default
|
||||
// policy exempts only "local" destinations — `.local` names, unqualified names, and RFC1918 /
|
||||
// link-local IP literals. Everything else must present a certificate that passes system trust
|
||||
// evaluation. A punktfunk host is self-signed by construction (there is no CA that could vouch
|
||||
// for a box on someone's LAN), so the library worked at 192.168.x and died at the TLS layer on
|
||||
// every other address: a Tailscale peer (100.64/10 is CGNAT, NOT RFC1918), a WireGuard peer, or a
|
||||
// public IP. No ATS key can express "any address the user typed" — the exception keys are
|
||||
// domain-scoped — so the only ways out were disabling ATS app-wide (which also drops the TLS
|
||||
// floor and the cleartext block on third-party cover-art fetches, the one surface we did NOT want
|
||||
// to open) or leaving the URL loading system for this one origin. This is that second option.
|
||||
//
|
||||
// Network.framework is not subject to ATS, and `sec_protocol_options_set_verify_block` lets us
|
||||
// state the trust rule we actually mean: the leaf certificate must hash to the fingerprint the
|
||||
// user pinned during PIN pairing. That is a stronger check than CA trust here, not a weaker one,
|
||||
// and it is the same rule the QUIC stream plane has always applied via punktfunk-core — which is
|
||||
// precisely why streaming kept working over Tailscale while the library did not.
|
||||
//
|
||||
// Connections are POOLED and kept alive: a library screen fetches one JSON payload and then a
|
||||
// poster per title, and giving each its own TLS handshake was pure latency. `MgmtConnectionPool`
|
||||
// keeps a small number of connections per host, hands them out one request at a time, and makes
|
||||
// callers wait rather than opening an unbounded number.
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
import Security
|
||||
|
||||
enum MgmtTransportError: Error, Sendable {
|
||||
/// The host's certificate did not hash to the pinned fingerprint — an impostor, or a host
|
||||
/// that was reinstalled/re-keyed since pairing.
|
||||
case pinMismatch
|
||||
case connection(String)
|
||||
case timedOut
|
||||
case tooLarge
|
||||
case invalidPort(UInt16)
|
||||
}
|
||||
|
||||
enum MgmtTransport {
|
||||
/// Largest response we will buffer. The host's art proxy serves Steam hero images that run to
|
||||
/// a few MB; anything past this is not a poster and not a library payload.
|
||||
static let maxResponseBytes = 16 * 1024 * 1024
|
||||
|
||||
/// `GET https://host:port/path`, authenticated by mTLS (`identity`) and pinned by
|
||||
/// `pinnedHostFingerprint` (nil = trust-on-first-use, matching the QUIC connect's semantics).
|
||||
///
|
||||
/// Runs over a pooled keep-alive connection. A connection the host has since dropped is
|
||||
/// indistinguishable from a live one until we write to it, so a REUSED connection that fails
|
||||
/// is retried once on a fresh one; a fresh connection that fails is a real error.
|
||||
static func get(
|
||||
host: String,
|
||||
port: UInt16,
|
||||
path: String,
|
||||
identity: SecIdentity,
|
||||
pinnedHostFingerprint: Data?,
|
||||
timeout: TimeInterval = 15
|
||||
) async throws -> HTTPResponse {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else {
|
||||
throw MgmtTransportError.invalidPort(port)
|
||||
}
|
||||
let pin = pinnedHostFingerprint
|
||||
let key = "\(unbracketed(host)):\(port):\(pin.map(hex) ?? "tofu")"
|
||||
var lastError: Error = MgmtTransportError.connection("no attempt made")
|
||||
|
||||
for attempt in 0..<2 {
|
||||
let connection = await MgmtConnectionPool.shared.acquire(key: key) {
|
||||
MgmtConnection(host: unbracketed(host), port: nwPort, identity: identity, pin: pin)
|
||||
}
|
||||
let wasReused = connection.hasServedRequest
|
||||
do {
|
||||
let response = try await connection.perform(path: path, timeout: timeout)
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
return response
|
||||
} catch {
|
||||
await MgmtConnectionPool.shared.release(connection, key: key)
|
||||
lastError = error
|
||||
// Only a reused connection earns a second try, and only once: retrying a fresh
|
||||
// connection would just double every genuine failure's latency.
|
||||
if !wasReused || attempt == 1 { throw error }
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
static func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Saved hosts store bare addresses, but a user who pasted a bracketed IPv6 literal shouldn't
|
||||
/// get an unresolvable endpoint out of it.
|
||||
static func unbracketed(_ host: String) -> String {
|
||||
guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host }
|
||||
return String(host.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
|
||||
/// A pool of keep-alive connections, at most `maxPerHost` per host. Callers past that wait for one
|
||||
/// to come back rather than opening more — a library grid can ask for dozens of posters at once,
|
||||
/// and answering that with dozens of TLS handshakes is what this exists to prevent.
|
||||
actor MgmtConnectionPool {
|
||||
static let shared = MgmtConnectionPool()
|
||||
|
||||
private var available: [String: [MgmtConnection]] = [:]
|
||||
/// Connections created and not yet closed, per host — the cap this pool enforces.
|
||||
private var live: [String: Int] = [:]
|
||||
private var waiters: [String: [CheckedContinuation<Void, Never>]] = [:]
|
||||
private let maxPerHost = 4
|
||||
|
||||
func acquire(key: String, make: () -> MgmtConnection) async -> MgmtConnection {
|
||||
while true {
|
||||
if var idle = available[key], let connection = idle.popLast() {
|
||||
available[key] = idle
|
||||
if connection.isHealthy { return connection }
|
||||
connection.close()
|
||||
live[key] = max(0, (live[key] ?? 1) - 1)
|
||||
continue
|
||||
}
|
||||
if (live[key] ?? 0) < maxPerHost {
|
||||
live[key] = (live[key] ?? 0) + 1
|
||||
return make()
|
||||
}
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
waiters[key, default: []].append(continuation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Always call this, on success AND on failure: a connection that is never returned leaks a
|
||||
/// slot, and enough leaked slots would hang every later request on the waiter queue.
|
||||
func release(_ connection: MgmtConnection, key: String) {
|
||||
if connection.isHealthy, (available[key]?.count ?? 0) < maxPerHost {
|
||||
available[key, default: []].append(connection)
|
||||
} else {
|
||||
connection.close()
|
||||
live[key] = max(0, (live[key] ?? 1) - 1)
|
||||
}
|
||||
if var queue = waiters[key], !queue.isEmpty {
|
||||
let next = queue.removeFirst()
|
||||
waiters[key] = queue
|
||||
next.resume()
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every idle connection for a host — used when a library screen goes away, so we don't
|
||||
/// sit on sockets the user is done with.
|
||||
func closeAll(matching prefix: String) {
|
||||
for (key, connections) in available where key.hasPrefix(prefix) {
|
||||
connections.forEach { $0.close() }
|
||||
live[key] = max(0, (live[key] ?? 0) - connections.count)
|
||||
available[key] = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One TLS connection to a host, serving requests one at a time. The pool guarantees a single
|
||||
/// caller at a time, so there is no request queueing here.
|
||||
///
|
||||
/// Everything mutable is touched only on `queue`, which also runs the connection's callbacks, the
|
||||
/// verify block and the timeout — so the state below needs no locking and two callbacks can never
|
||||
/// race to resume the same continuation.
|
||||
final class MgmtConnection: @unchecked Sendable {
|
||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.mgmt-connection")
|
||||
private let connection: NWConnection
|
||||
private let host: String
|
||||
private let port: UInt16
|
||||
|
||||
private enum Phase { case idle, connecting, ready, dead }
|
||||
private var phase: Phase = .idle
|
||||
private var pending: CheckedContinuation<HTTPResponse, Error>?
|
||||
private var pendingRequest: Data?
|
||||
/// Bytes read past the end of the last response. Non-empty only if a host pipelines ahead of
|
||||
/// us, which none do — but dropping them would silently corrupt the next read.
|
||||
private var buffer = Data()
|
||||
private var operation = 0
|
||||
private var pinRejected = false
|
||||
private var servedRequest = false
|
||||
|
||||
/// False once the connection has failed; the pool discards these instead of handing them out.
|
||||
private(set) var isHealthy = true
|
||||
/// Has this connection completed at least one request? Drives the retry-once rule in
|
||||
/// `MgmtTransport.get` — only a connection the host may have dropped since is worth retrying.
|
||||
var hasServedRequest: Bool { servedRequest }
|
||||
|
||||
init(host: String, port: NWEndpoint.Port, identity: SecIdentity, pin: Data?) {
|
||||
self.host = host
|
||||
self.port = port.rawValue
|
||||
let options = NWProtocolTLS.Options()
|
||||
let sec = options.securityProtocolOptions
|
||||
sec_protocol_options_set_min_tls_protocol_version(sec, .TLSv12)
|
||||
// Our half of the mTLS handshake: the same paired identity the host authorizes the
|
||||
// read-only library routes by (mgmt/auth.rs `cert_may_access`).
|
||||
if let secIdentity = sec_identity_create(identity) {
|
||||
sec_protocol_options_set_local_identity(sec, secIdentity)
|
||||
}
|
||||
let rejected = RejectionFlag()
|
||||
// Replaces system trust evaluation wholesale, which is the point: the host is self-signed
|
||||
// and carries no SAN, so there is nothing for the system policy to succeed at. Pinning the
|
||||
// leaf's SHA-256 is the real check.
|
||||
sec_protocol_options_set_verify_block(sec, { _, trust, complete in
|
||||
let secTrust = sec_trust_copy_ref(trust).takeRetainedValue()
|
||||
guard let chain = SecTrustCopyCertificateChain(secTrust) as? [SecCertificate],
|
||||
let leaf = chain.first
|
||||
else {
|
||||
rejected.value = true
|
||||
complete(false)
|
||||
return
|
||||
}
|
||||
guard let pin else {
|
||||
complete(true) // trust-on-first-use: no pin recorded for this host yet
|
||||
return
|
||||
}
|
||||
let fingerprint = Data(SHA256.hash(data: SecCertificateCopyData(leaf) as Data))
|
||||
let matches = fingerprint == pin
|
||||
if !matches { rejected.value = true }
|
||||
complete(matches)
|
||||
}, queue)
|
||||
self.connection = NWConnection(
|
||||
to: .hostPort(host: NWEndpoint.Host(host), port: port),
|
||||
using: NWParameters(tls: options, tcp: NWProtocolTCP.Options()))
|
||||
self.rejection = rejected
|
||||
self.connection.stateUpdateHandler = { [weak self] state in
|
||||
self?.handle(state)
|
||||
}
|
||||
}
|
||||
|
||||
/// Set from the verify block, read when mapping the resulting handshake failure. Its own
|
||||
/// object because the block is built before `self` exists.
|
||||
private let rejection: RejectionFlag
|
||||
private final class RejectionFlag: @unchecked Sendable { var value = false }
|
||||
|
||||
func perform(path: String, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
guard self.phase != .dead else {
|
||||
continuation.resume(throwing: MgmtTransportError.connection("connection closed"))
|
||||
return
|
||||
}
|
||||
self.operation += 1
|
||||
let op = self.operation
|
||||
self.pending = continuation
|
||||
self.pendingRequest = self.requestBytes(path: path)
|
||||
self.buffer.removeAll(keepingCapacity: true)
|
||||
self.queue.asyncAfter(deadline: .now() + timeout) { [weak self] in
|
||||
guard let self, self.operation == op else { return }
|
||||
self.finish(.failure(MgmtTransportError.timedOut))
|
||||
}
|
||||
switch self.phase {
|
||||
case .idle:
|
||||
self.phase = .connecting
|
||||
self.connection.start(queue: self.queue)
|
||||
case .ready:
|
||||
self.send()
|
||||
case .connecting, .dead:
|
||||
break // `.ready` (or a failure) will pick the pending request up
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
queue.async {
|
||||
self.phase = .dead
|
||||
self.isHealthy = false
|
||||
self.connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue-confined internals
|
||||
|
||||
private func handle(_ state: NWConnection.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
phase = .ready
|
||||
if pendingRequest != nil { send() }
|
||||
case .failed(let error):
|
||||
phase = .dead
|
||||
isHealthy = false
|
||||
finish(.failure(mapped(error)))
|
||||
case .cancelled:
|
||||
phase = .dead
|
||||
isHealthy = false
|
||||
finish(.failure(MgmtTransportError.connection("cancelled")))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func send() {
|
||||
guard let request = pendingRequest else { return }
|
||||
pendingRequest = nil
|
||||
connection.send(content: request, completion: .contentProcessed { [weak self] error in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(self.mapped(error)))
|
||||
return
|
||||
}
|
||||
self.receive()
|
||||
})
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) {
|
||||
[weak self] chunk, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
if let chunk, !chunk.isEmpty { self.buffer.append(chunk) }
|
||||
if self.buffer.count > MgmtTransport.maxResponseBytes {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(MgmtTransportError.tooLarge))
|
||||
return
|
||||
}
|
||||
if let error {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(self.mapped(error)))
|
||||
return
|
||||
}
|
||||
do {
|
||||
if let length = try HTTPResponseParser.messageLength(in: self.buffer) {
|
||||
let message = self.buffer.prefix(length)
|
||||
self.buffer = Data(self.buffer.dropFirst(length))
|
||||
let response = try HTTPResponseParser.parse(message)
|
||||
// A response the peer means to be last leaves nothing reusable behind. Nor
|
||||
// does a stream with bytes left over: we never pipeline, so anything trailing
|
||||
// means we are out of sync, and reusing the connection would misread the next
|
||||
// response rather than fail cleanly.
|
||||
if response.wantsClose || !self.buffer.isEmpty { self.isHealthy = false }
|
||||
self.servedRequest = true
|
||||
self.finish(.success(response))
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
// No framing header: the body ran to EOF, so what we have is the whole thing
|
||||
// and the connection is spent.
|
||||
self.isHealthy = false
|
||||
let response = try HTTPResponseParser.parse(self.buffer)
|
||||
self.servedRequest = true
|
||||
self.finish(.success(response))
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
self.isHealthy = false
|
||||
self.finish(.failure(error))
|
||||
return
|
||||
}
|
||||
self.receive()
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard let continuation = pending else { return }
|
||||
pending = nil
|
||||
operation += 1 // invalidate this operation's timeout
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
|
||||
/// A rejected pin surfaces as a generic handshake failure; the flag is how we recover what
|
||||
/// actually happened, so the UI can say "re-pair" instead of "offline".
|
||||
private func mapped(_ error: NWError) -> MgmtTransportError {
|
||||
rejection.value ? .pinMismatch : .connection(String(describing: error))
|
||||
}
|
||||
|
||||
private func requestBytes(path: String) -> Data {
|
||||
// An IPv6 literal is bracketed in the Host header (RFC 9110 §7.2); a name or IPv4 is not.
|
||||
let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)"
|
||||
let request = """
|
||||
GET \(path) HTTP/1.1\r
|
||||
Host: \(authority)\r
|
||||
User-Agent: punktfunk-apple\r
|
||||
Accept: */*\r
|
||||
\r
|
||||
|
||||
"""
|
||||
return Data(request.utf8)
|
||||
}
|
||||
}
|
||||
@@ -78,4 +78,234 @@ final class LibraryClientTests: XCTestCase {
|
||||
XCTAssertEqual(resolved.hero, "https://cdn.example.com/hero.jpg") // unchanged
|
||||
XCTAssertNil(resolved.logo)
|
||||
}
|
||||
|
||||
// MARK: - HTTP response parsing (MgmtTransport)
|
||||
|
||||
// The management API is reached over Network.framework rather than URLSession (ATS cannot be
|
||||
// relaxed for the arbitrary addresses a host lives at — see MgmtTransport), so we parse HTTP
|
||||
// ourselves. These cover the framings hyper actually emits, plus the failure modes where
|
||||
// getting it wrong would be silent.
|
||||
|
||||
private func raw(_ text: String) -> Data { Data(text.utf8) }
|
||||
|
||||
func testParsesContentLengthFramedJSON() throws {
|
||||
let body = #"[{"id":"steam:570"}]"#
|
||||
let response = try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
|
||||
+ "Content-Length: \(body.utf8.count)\r\n\r\n\(body)"))
|
||||
XCTAssertEqual(response.status, 200)
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), body)
|
||||
// Field names are case-insensitive per RFC 9110.
|
||||
XCTAssertEqual(response.header("CONTENT-TYPE"), "application/json")
|
||||
}
|
||||
|
||||
func testParsesChunkedBody() throws {
|
||||
// How hyper streams the art proxy.
|
||||
let response = try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
+ "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "hello world")
|
||||
}
|
||||
|
||||
func testChunkExtensionsAndTrailersAreIgnored() throws {
|
||||
let response = try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
+ "3;foo=bar\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
|
||||
}
|
||||
|
||||
func testUnauthorizedStatusSurvives() throws {
|
||||
// What an unpaired certificate gets from the host — the status is the whole signal.
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"))
|
||||
XCTAssertEqual(response.status, 401)
|
||||
XCTAssertTrue(response.body.isEmpty)
|
||||
}
|
||||
|
||||
func testBodyRunsToEOFWithoutFramingHeaders() throws {
|
||||
let response = try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\n\r\nraw-to-eof"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "raw-to-eof")
|
||||
}
|
||||
|
||||
func testTruncatedBodyThrowsRatherThanReturningPartialJSON() {
|
||||
// The one that matters: a body cut short must NOT come back as success. A clipped JSON
|
||||
// array would decode to fewer games — "this host has no games" — instead of an error.
|
||||
XCTAssertThrowsError(
|
||||
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 99\r\n\r\nshort")))
|
||||
}
|
||||
|
||||
func testOverLongBodyIsClippedToContentLength() throws {
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcdef"))
|
||||
XCTAssertEqual(String(decoding: response.body, as: UTF8.self), "abc")
|
||||
}
|
||||
|
||||
func testMalformedResponsesThrow() {
|
||||
// Header block never terminated (peer hung up), a non-HTTP greeting, and a chunked stream
|
||||
// cut mid-chunk.
|
||||
XCTAssertThrowsError(
|
||||
try HTTPResponseParser.parse(raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n")))
|
||||
XCTAssertThrowsError(try HTTPResponseParser.parse(raw("NOT-HTTP\r\n\r\n")))
|
||||
XCTAssertThrowsError(try HTTPResponseParser.parse(raw(
|
||||
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n9\r\nabc")))
|
||||
}
|
||||
|
||||
func testMultiWordReasonPhraseParses() throws {
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"))
|
||||
XCTAssertEqual(response.status, 404)
|
||||
}
|
||||
|
||||
func testBinaryBodySurvivesByteForByte() throws {
|
||||
// Posters are PNG/JPEG: the body must never be round-tripped through a String.
|
||||
let png: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0x0D, 0x0A]
|
||||
var message = raw("HTTP/1.1 200 OK\r\nContent-Length: \(png.count)\r\n\r\n")
|
||||
message.append(contentsOf: png)
|
||||
let response = try HTTPResponseParser.parse(message)
|
||||
XCTAssertEqual([UInt8](response.body), png)
|
||||
}
|
||||
|
||||
// MARK: - Message framing (keep-alive)
|
||||
|
||||
// Connections are pooled and reused, so a response has to be delimited WITHOUT waiting for
|
||||
// the peer to hang up. Getting this wrong either truncates a response or bleeds one response
|
||||
// into the next, and both would be silent.
|
||||
|
||||
func testMessageLengthDelimitsContentLengthFraming() throws {
|
||||
let complete = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel")))
|
||||
}
|
||||
|
||||
func testMessageLengthDelimitsChunkedFraming() throws {
|
||||
let complete = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: complete), complete.count)
|
||||
// Mid-chunk, and terminal chunk without its closing blank line.
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhel")))
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(
|
||||
in: raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n")))
|
||||
// Trailers belong to the message and must be consumed with it.
|
||||
let trailered = raw("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||
+ "3\r\nabc\r\n0\r\nX-Trailer: 1\r\n\r\n")
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: trailered), trailered.count)
|
||||
}
|
||||
|
||||
func testMessageLengthIsNilWithoutFraming() throws {
|
||||
// No Content-Length and not chunked ⇒ the body runs to EOF and the connection can't be
|
||||
// reused. Partial header blocks are likewise "not yet".
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\n\r\nto-eof")))
|
||||
XCTAssertNil(try HTTPResponseParser.messageLength(in: raw("HTTP/1.1 200 OK\r\nContent-Len")))
|
||||
}
|
||||
|
||||
func testBackToBackResponsesSplitExactly() throws {
|
||||
// The reuse case that matters: two responses arriving in one read.
|
||||
let first = raw("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
|
||||
let second = raw("HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nbye")
|
||||
let both = first + second
|
||||
XCTAssertEqual(try HTTPResponseParser.messageLength(in: both), first.count)
|
||||
XCTAssertEqual(
|
||||
String(decoding: try HTTPResponseParser.parse(both.prefix(first.count)).body,
|
||||
as: UTF8.self), "hello")
|
||||
XCTAssertEqual(
|
||||
String(decoding: try HTTPResponseParser.parse(both.dropFirst(first.count)).body,
|
||||
as: UTF8.self), "bye")
|
||||
}
|
||||
|
||||
func testConnectionCloseIsDetected() throws {
|
||||
let response = try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"))
|
||||
XCTAssertTrue(response.wantsClose)
|
||||
// HTTP/1.1 keeps the connection open unless told otherwise.
|
||||
XCTAssertFalse(try HTTPResponseParser.parse(
|
||||
raw("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")).wantsClose)
|
||||
}
|
||||
|
||||
// MARK: - Art cache
|
||||
|
||||
private func temporaryCacheDirectory() -> URL {
|
||||
URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("pf-art-test-\(UUID().uuidString)", isDirectory: true)
|
||||
}
|
||||
|
||||
func testArtCacheRoundTripsBinaryAndSeparatesKeys() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory)
|
||||
|
||||
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
|
||||
let portrait = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/portrait")!
|
||||
let header = URL(string: "https://100.64.1.2:47990/api/v1/library/art/steam:570/header")!
|
||||
|
||||
var hit = await cache.data(for: portrait)
|
||||
XCTAssertNil(hit, "cold cache must miss")
|
||||
await cache.store(png, for: portrait)
|
||||
hit = await cache.data(for: portrait)
|
||||
XCTAssertEqual(hit, png, "posters are binary; the body must survive byte-for-byte")
|
||||
// Sibling art of the same title must not collide.
|
||||
let sibling = await cache.data(for: header)
|
||||
XCTAssertNil(sibling)
|
||||
}
|
||||
|
||||
func testArtCacheRefusesEmptyAndInlineData() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory)
|
||||
|
||||
let empty = URL(string: "https://cdn.example.com/empty.jpg")!
|
||||
await cache.store(Data(), for: empty)
|
||||
let emptyHit = await cache.data(for: empty)
|
||||
XCTAssertNil(emptyHit, "an empty body is not art")
|
||||
|
||||
let inline = URL(string: "data:image/png;base64,iVBORw0KGgo=")!
|
||||
await cache.store(Data("x".utf8), for: inline)
|
||||
let inlineHit = await cache.data(for: inline)
|
||||
XCTAssertNil(inlineHit, "a data: URL is already inline — caching it is a pure loss")
|
||||
}
|
||||
|
||||
func testArtCacheAgesEntriesOut() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let cache = ArtCache(directory: directory, maxAge: 0.4)
|
||||
let url = URL(string: "https://cdn.example.com/stale.jpg")!
|
||||
|
||||
await cache.store(Data("stale".utf8), for: url)
|
||||
let fresh = await cache.data(for: url)
|
||||
XCTAssertNotNil(fresh)
|
||||
try await Task.sleep(nanoseconds: 700_000_000)
|
||||
let expired = await cache.data(for: url)
|
||||
XCTAssertNil(expired)
|
||||
}
|
||||
|
||||
func testArtCacheEvictsLeastRecentlyUsedOverBudget() async throws {
|
||||
let directory = temporaryCacheDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
// Budget holds three of these; the fourth store must evict.
|
||||
let cache = ArtCache(directory: directory, maxBytes: 300)
|
||||
let blob = Data(repeating: 0x41, count: 100)
|
||||
var urls: [URL] = []
|
||||
for i in 0..<4 {
|
||||
let url = URL(string: "https://cdn.example.com/blob\(i).jpg")!
|
||||
urls.append(url)
|
||||
await cache.store(blob, for: url)
|
||||
try await Task.sleep(nanoseconds: 60_000_000) // distinct mtimes for LRU ordering
|
||||
}
|
||||
let evicted = await cache.data(for: urls[0])
|
||||
XCTAssertNil(evicted, "the oldest entry should have been evicted")
|
||||
let newest = await cache.data(for: urls[3])
|
||||
XCTAssertEqual(newest, blob)
|
||||
}
|
||||
|
||||
func testBaseURLBracketsIPv6Only() {
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "192.168.1.70", port: 47990),
|
||||
"https://192.168.1.70:47990")
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "100.101.102.103", port: 47990),
|
||||
"https://100.101.102.103:47990")
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "fd7a:115c::1", port: 47990),
|
||||
"https://[fd7a:115c::1]:47990")
|
||||
// An address the user pasted already bracketed must not end up double-bracketed.
|
||||
XCTAssertEqual(LibraryClient.baseURL(address: "[fd7a:115c::1]", port: 47990),
|
||||
"https://[fd7a:115c::1]:47990")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user