import Foundation /// Episodes kept on the device to play with no connection (#425). The page's Keep button asks /// (through the bridge); a background `URLSession` fetches, so a download goes on with the app /// closed and the phone locked; `Playback` plays from the file when there is one. /// /// A browser keeps them in a cache its service worker answers from, which a `WKWebView` does not /// run, so the app keeps its own and the page is told which it holds. @MainActor final class Offline: NSObject, URLSessionDownloadDelegate { static let shared = Offline() /// What is kept, or being fetched, changed. var onChange: (() -> Void)? /// A path that could not be kept, and why in words for the person. var onFail: ((String, String) -> Void)? /// The system's handler for a launch it made to deliver a finished download: told when done. var backgroundCompletion: (() -> Void)? private(set) var store: EpisodeStore private(set) var downloading: Set = [] private lazy var session: URLSession = { let cfg = URLSessionConfiguration.background(withIdentifier: "net.sdf1.ipx.offline") cfg.sessionSendsLaunchEvents = true cfg.httpCookieStorage = .shared // The delegate's queue is main, which is what lets this class be the main actor's. return URLSession(configuration: cfg, delegate: self, delegateQueue: .main) }() private override init() { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] store = EpisodeStore(directory: base.appendingPathComponent("episodes", isDirectory: true)) super.init() } /// At launch: reconnects to downloads begun before the app was last closed, so the page can be /// told they are still going. func resume() { session.getAllTasks { [weak self] tasks in let paths = tasks.compactMap { $0.taskDescription } Task { @MainActor in self?.downloading.formUnion(paths) if !paths.isEmpty { self?.onChange?() } } } } func keep(_ path: String) { guard EpisodeStore.isKeepable(path) else { return } guard store.fileURL(for: path) == nil, !downloading.contains(path) else { onChange?(); return } guard let url = Server.resolveForPlayer(path) else { onFail?(path, "Set the server first.") return } var req = URLRequest(url: url) Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) } let task = session.downloadTask(with: req) task.taskDescription = path downloading.insert(path) task.resume() onChange?() } func remove(_ path: String) { session.getAllTasks { tasks in tasks.filter { $0.taskDescription == path }.forEach { $0.cancel() } } downloading.remove(path) store.remove(path) onChange?() } /// Signing out: the next person on this phone is not to hear what was kept for the last. func removeAll() { session.getAllTasks { tasks in tasks.forEach { $0.cancel() } } downloading = [] store.removeAll() onChange?() } // MARK: - URLSessionDownloadDelegate nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { // On the main queue (see `session`), and the file is the system's until this returns, so // it is moved here and not later. MainActor.assumeIsolated { guard let path = downloadTask.taskDescription else { return } let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0 guard status == 200 else { downloading.remove(path) onFail?(path, status == 401 || status == 403 ? "Check the player token in Settings." : "The server said \(status).") onChange?() return } let ext = EpisodeStore.ext(forMime: downloadTask.response?.mimeType) do { try store.add(path, moving: location, ext: ext) } catch { onFail?(path, "There is not enough room on this phone to keep it.") } downloading.remove(path) onChange?() } } nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let error, (error as NSError).code != NSURLErrorCancelled else { return } MainActor.assumeIsolated { guard let path = task.taskDescription else { return } downloading.remove(path) onFail?(path, (error as NSError).code == NSURLErrorNotConnectedToInternet ? "You are offline. Keep it once you are back." : "Unable to keep it on this device. Try again.") onChange?() } } nonisolated func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { MainActor.assumeIsolated { backgroundCompletion?() backgroundCompletion = nil } } }