iOS: episodes kept on the phone play with no connection (#425)

A WKWebView runs no service worker, so the page's Keep button (#412)
was not shown in the app and everything played from the server. Now the
page asks the app to keep the file (keep, unkeep, clearKept), and the
app answers with what it holds (kept, keepFailed) whether or not
anything plays. The app fetches with a background URLSession, so a
download goes on with the app closed, into Application Support (not
backed up; the files can be fetched again), and Playback plays from the
file when there is one. The DeadAir copy is kept under its own path, as
the browser keeps it. Places and finishes reached offline wait in an
outbox, newest per address, and go when the network is back.

Checked in the simulator against a local server: kept /media/42 and
/media/42/deadair, then with the server refusing /media, a fresh launch
played each from the phone and asked the server for nothing. The page
itself still needs a connection to open (#435).

Deploy the app (build 6) before the page: a new page in an old app
shows a Keep button nothing answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 14:40:01 -05:00
parent 05e522ac93
commit ecc248307e
14 changed files with 466 additions and 10 deletions

124
ios/Sources/Offline.swift Normal file
View File

@@ -0,0 +1,124 @@
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<String> = []
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
}
}
}