import AVFoundation import Foundation import os private let logger = Logger(subsystem: "dev.shane.voice", category: "media") /// Proxies {base}/media/* bytes to AVPlayer over the pinned-TLS session, since /// AVPlayer cannot trust the self-signed LAN cert. AVPlayer loads https natively /// (ignoring registered URLProtocol subclasses), so the asset uses a custom /// `pinned-media` scheme and this delegate answers every data request by /// fetching the real https URL through TLSPinning.session(), forwarding Range. final class PinnedMediaResourceLoader: NSObject, AVAssetResourceLoaderDelegate { static let scheme = "pinned-media" private let base: URL private var loaderURLs: [ObjectIdentifier: URL] = [:] init(base: URL) { self.base = base } /// Maps a media URL (absolute https or /media/... path) to the pinned-media /// scheme so AVPlayer routes it through this delegate instead of loading /// https natively. Built as a string because URLComponents.url returns nil /// for schemes not in Foundation's known-scheme list. func rewrite(_ url: URL) -> URL { let absolute: String if let scheme = url.scheme?.lowercased(), scheme.hasPrefix("http") { var c = URLComponents(url: url, resolvingAgainstBaseURL: false)! c.scheme = Self.scheme return c.url! } else { let path = url.absoluteString.hasPrefix("/") ? String(url.absoluteString.dropFirst()) : url.absoluteString guard let host = base.host, !host.isEmpty else { return url } let port = base.port.map { ":\($0)" } ?? "" absolute = "\(Self.scheme)://\(host)\(port)/\(path)" } return URL(string: absolute) ?? url } /// Maps a pinned-media URL back to the real https URL it proxies. func resolve(_ url: URL) -> URL { guard let scheme = url.scheme?.lowercased(), scheme == Self.scheme else { return url } var c = URLComponents(url: url, resolvingAgainstBaseURL: false)! c.scheme = "https" return c.url ?? url } func assetWithPinnedURL(_ pinned: URL) -> AVURLAsset { let asset = AVURLAsset(url: pinned) loaderURLs[ObjectIdentifier(asset.resourceLoader)] = resolve(pinned) asset.resourceLoader.setDelegate(self, queue: .main) return asset } func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest ) -> Bool { guard let dataRequest = loadingRequest.dataRequest else { return false } guard let url = loaderURLs[ObjectIdentifier(resourceLoader)] else { logger.log("no resolved URL for resource loader") loadingRequest.finishLoading(with: URLError(.badURL)) return false } var upstream = URLRequest(url: url) let offset = dataRequest.requestedOffset let length = Int64(dataRequest.requestedLength) if length > 0 { upstream.setValue("bytes=\(offset)-\(offset + length - 1)", forHTTPHeaderField: "Range") } else { upstream.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") } logger.log("fetching \(url.absoluteString, privacy: .public) range=\(upstream.value(forHTTPHeaderField: "Range") ?? "-", privacy: .public)") let task = TLSPinning.session().dataTask(with: upstream) { data, response, error in if let error { logger.log("fetch failed \(url.absoluteString, privacy: .public): \(error.localizedDescription, privacy: .public)") loadingRequest.finishLoading(with: error) return } guard let http = response as? HTTPURLResponse else { loadingRequest.finishLoading(with: URLError(.badServerResponse)) return } if let info = loadingRequest.contentInformationRequest { info.contentType = http.mimeType ?? "application/octet-stream" if http.expectedContentLength >= 0 { info.contentLength = Int64(http.expectedContentLength) } info.isByteRangeAccessSupported = true } if let data { dataRequest.respond(with: data) } loadingRequest.finishLoading() } task.resume() return true } func resourceLoader( _ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest ) { logger.log("resource loader cancelled request") } }