import Foundation import Network /// Writes the player made with no connection, sent when there is one (#425): where you got to in /// an episode, and that you finished it. In a browser the page's service worker holds these; the /// app's player is the one writing them, so it holds its own. /// /// A newer write to the same address replaces an older one, since each says where you are now, and /// the queue then stays as long as the episodes played offline, not as long as the time spent. enum Outbox { struct Write: Codable, Equatable { var path: String var json: Data } private static let key = "ipx.outbox" private static var flushing = false private static let monitor = NWPathMonitor() static var pending: [Write] { get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode([Write].self, from: $0) } ?? [] } set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) } } /// A write that never arrived. Only the player's own two kinds are kept: anything else, such /// as adding a feed, fails offline as it always has. static func add(path: String, json: [String: Any]) { guard path.hasSuffix("/position") || path.hasSuffix("/flags"), let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) else { return } var all = pending.filter { $0.path != path } all.append(Write(path: path, json: data)) pending = all } /// In the order they were made. The first that does not reach the server stops it for the next /// try; one the server answered at all is done, whatever it said, as the worker treats it. static func flush() async { guard !flushing, !pending.isEmpty else { return } flushing = true defer { flushing = false } let cfg = URLSessionConfiguration.default cfg.httpCookieStorage = .shared cfg.timeoutIntervalForRequest = 20 let session = URLSession(configuration: cfg) for w in pending { guard let url = Server.resolveForPlayer(w.path) else { return } var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) } req.httpBody = w.json guard (try? await session.data(for: req)) != nil else { return } // Removed by what it is, not where it was: a newer write may have replaced it while // this one was on its way. pending.removeAll { $0 == w } } } /// Sends what waited whenever the phone gets a connection back. static func watch() { monitor.pathUpdateHandler = { path in if path.status == .satisfied { Task { await flush() } } } monitor.start(queue: .global(qos: .utility)) } }