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

View File

@@ -9,3 +9,4 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
- iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418).
- Player token (shake, then Player Token): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421).
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).

View File

@@ -69,6 +69,8 @@ Page to app, on `webkit.messageHandlers.ipx`:
| `{t:"play"}` `{t:"pause"}` `{t:"stop"}` | |
| `{t:"seek", to}` `{t:"rate", v}` `{t:"volume", v}` | |
| `{t:"position", url}` | save where we are; a path, not a time, since a page frozen in the background holds an old one |
| `{t:"keep", url}` `{t:"unkeep", url}` | keep a file on the phone, or let it go: the page's path for it, `/media/42` or `/media/42/deadair` (#425) |
| `{t:"clearKept"}` | signing out: everything kept goes |
App to page, as `window.ipxNative.on(...)`:
@@ -79,14 +81,19 @@ App to page, as `window.ipxNative.on(...)`:
| `{t:"state", playing}` | `play` or `pause` |
| `{t:"ended"}` | |
| `{t:"error", message}` | the page says so |
| `{t:"kept", urls, keeping}` | what the phone holds and is fetching, at `ready` and after each change; sent whether or not anything plays |
| `{t:"keepFailed", url, message}` | one that could not be kept, and why |
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from
its own clock, so both happen while the page is frozen. Video stays in the page.
## Not yet
* Episodes kept on the device (#412) are the page's service worker's, which a `WKWebView` does
not run without App-Bound Domains; the app plays them from the server.
* Episodes kept on the phone (#425) play with no connection, from files the app fetches with a
background `URLSession` (`Offline.swift`, `EpisodeStore.swift`); a place or a finish reached
offline waits in `Outbox.swift` until the connection is back. But the page itself does not open
without a connection: a `WKWebView` runs no service worker without App-Bound Domains (#435). So
keep what you want, open the app while online, and it plays on with the connection gone.
* Without the player token the cookie is the only credential: when Cloudflare Access's expires
mid-drive, playback stops with "Sign in again", and opening the app signs in.
* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same

View File

@@ -5,8 +5,18 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
Badge.registerBackgroundRefresh()
Offline.shared.resume()
Outbox.watch()
return true
}
/// A download kept going while the app was closed has finished (#425): the system launched us
/// to hand it over, and waits on this handler.
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void) {
Offline.shared.backgroundCompletion = completionHandler
Offline.shared.resume()
}
}
/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would
@@ -32,6 +42,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneDidBecomeActive(_ scene: UIScene) {
Badge.requestPermission()
Badge.scheduleBackgroundRefresh()
Task { await Badge.refresh() }
Task {
await Outbox.flush()
await Badge.refresh()
}
}
}

View File

@@ -29,6 +29,16 @@ final class Bridge: NSObject, WKScriptMessageHandler {
playback.onState = { [weak self] playing in self?.send(["t": "state", "playing": playing]) }
playback.onEnded = { [weak self] in self?.send(["t": "ended"]) }
playback.onError = { [weak self] msg in self?.send(["t": "error", "message": msg]) }
Offline.shared.onChange = { [weak self] in self?.sendKept() }
Offline.shared.onFail = { [weak self] path, msg in
self?.send(["t": "keepFailed", "url": path, "message": msg])
}
}
/// What the phone holds, and is fetching, by the paths the page keeps them under (#425).
private func sendKept() {
send(["t": "kept", "urls": Offline.shared.store.paths,
"keeping": Offline.shared.downloading.sorted()])
}
// MARK: - page to host
@@ -40,6 +50,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
case "ready":
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
sendKept()
case "load":
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
let enc = m["enc"] as? Int,
@@ -59,6 +70,9 @@ final class Bridge: NSObject, WKScriptMessageHandler {
// The page sends the path it would have written to, not a time: ours is the clock that
// is not stale.
case "position": playback.savePosition()
case "keep": if let url = m["url"] as? String { Offline.shared.keep(url) }
case "unkeep": if let url = m["url"] as? String { Offline.shared.remove(url) }
case "clearKept": Offline.shared.removeAll()
default: break
}
}

View File

@@ -0,0 +1,89 @@
import Foundation
/// The episodes kept on this device (#425): which file holds each, by the path the page keeps it
/// under, `/media/42`, or `/media/42/deadair` for the copy without its dead air. A plain value over
/// a folder, so a test can point it at a temporary one.
struct EpisodeStore {
struct Entry: Codable, Equatable {
var file: String
var bytes: Int64
}
let directory: URL
private(set) var entries: [String: Entry]
private var indexURL: URL { directory.appendingPathComponent("index.json") }
init(directory: URL) {
self.directory = directory
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
// The files can be fetched again, so they are not worth the user's iCloud backup.
var dir = directory
var values = URLResourceValues()
values.isExcludedFromBackup = true
try? dir.setResourceValues(values)
let saved = (try? Data(contentsOf: directory.appendingPathComponent("index.json")))
.flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:]
// A file the system took back is not kept, whatever the index says.
entries = saved.filter {
FileManager.default.fileExists(atPath: directory.appendingPathComponent($0.value.file).path)
}
}
var paths: [String] { entries.keys.sorted() }
func fileURL(for path: String) -> URL? {
entries[path].map { directory.appendingPathComponent($0.file) }
}
/// Only the page's two kinds of file, which also keeps what a path can make of a file name to
/// digits and a word: a message from the page is not trusted to name a place on disk.
static func isKeepable(_ path: String) -> Bool {
path.range(of: #"^/media/[0-9]+(/deadair)?$"#, options: .regularExpression) != nil
}
static func fileName(for path: String, ext: String) -> String {
path.dropFirst().replacingOccurrences(of: "/", with: "-") + "." + ext
}
/// What AVFoundation can tell a file's type from, since a download has no name of its own.
static func ext(forMime mime: String?) -> String {
switch mime?.lowercased().split(separator: ";").first.map(String.init) {
case "audio/mp4", "audio/x-m4a", "audio/m4a": return "m4a"
case "audio/aac", "audio/aacp": return "aac"
case "audio/ogg", "audio/opus": return "ogg"
case "audio/wav", "audio/x-wav": return "wav"
case "audio/flac", "audio/x-flac": return "flac"
default: return "mp3"
}
}
/// Takes the file at `temp`, which the system deletes when the delegate returns, as `path`'s.
mutating func add(_ path: String, moving temp: URL, ext: String) throws {
precondition(Self.isKeepable(path))
let name = Self.fileName(for: path, ext: ext)
let dest = directory.appendingPathComponent(name)
try? FileManager.default.removeItem(at: dest)
try FileManager.default.moveItem(at: temp, to: dest)
let bytes = (try? FileManager.default.attributesOfItem(atPath: dest.path))?[.size] as? Int64 ?? 0
entries[path] = Entry(file: name, bytes: bytes)
save()
}
mutating func remove(_ path: String) {
guard let e = entries.removeValue(forKey: path) else { return }
try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file))
save()
}
mutating func removeAll() {
for e in entries.values { try? FileManager.default.removeItem(at: directory.appendingPathComponent(e.file)) }
entries = [:]
save()
}
private func save() {
if let data = try? JSONEncoder().encode(entries) { try? data.write(to: indexURL, options: .atomic) }
}
}

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
}
}
}

66
ios/Sources/Outbox.swift Normal file
View File

@@ -0,0 +1,66 @@
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))
}
}

View File

@@ -75,7 +75,10 @@ final class Playback: NSObject {
AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [],
]
if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers }
let asset = AVURLAsset(url: item.url, options: options)
// One kept on the phone plays from there, connection or not (#425). The page asks for it
// as `/media/42?offline`; the path alone is what it was kept under.
let asset = Offline.shared.store.fileURL(for: item.url.path).map { AVURLAsset(url: $0) }
?? AVURLAsset(url: item.url, options: options)
let playerItem = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: playerItem)
player.volume = volume
@@ -177,7 +180,10 @@ final class Playback: NSObject {
if let d = duration { body["duration"] = Int(d.rounded()) }
// DeadAir's copy runs on a shorter clock; the server has to know which one this is.
if item.url.path.contains("/deadair") { body["deadair"] = item.enc }
Task { await request("POST", entryPath(item, "position"), body) }
let path = entryPath(item, "position")
// Not arrived, kept for when the connection is back: offline, the ten-second retry would
// only fail again, and the place reached on the last pause would be lost.
Task { if await request("POST", path, body) == nil { Outbox.add(path: path, json: body) } }
}
// MARK: - as it plays
@@ -204,8 +210,9 @@ final class Playback: NSObject {
private func markRead() {
guard !markedRead, let item else { return }
markedRead = true
let path = entryPath(item, "flags")
Task {
await request("POST", entryPath(item, "flags"), ["read": true])
if await request("POST", path, ["read": true]) == nil { Outbox.add(path: path, json: ["read": true]) }
await Badge.refresh()
}
}

View File

@@ -0,0 +1,72 @@
import XCTest
@testable import iPX
final class OfflineTests: XCTestCase {
private var dir: URL!
override func setUp() {
dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
}
override func tearDown() {
try? FileManager.default.removeItem(at: dir)
UserDefaults.standard.removeObject(forKey: "ipx.outbox")
}
private func tempFile(_ bytes: Int = 4) -> URL {
let u = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try? Data(repeating: 7, count: bytes).write(to: u)
return u
}
func testOnlyThePagesTwoKindsOfFileAreKept() {
XCTAssertTrue(EpisodeStore.isKeepable("/media/42"))
XCTAssertTrue(EpisodeStore.isKeepable("/media/42/deadair"))
XCTAssertFalse(EpisodeStore.isKeepable("/media/42/deadair.m3u8"))
XCTAssertFalse(EpisodeStore.isKeepable("/media/../../etc/passwd"), "a path names a place on disk")
XCTAssertFalse(EpisodeStore.isKeepable("/api/feeds"))
}
func testAKeptFileIsFoundAgainAfterARelaunch() throws {
var store = EpisodeStore(directory: dir)
try store.add("/media/42", moving: tempFile(10), ext: "mp3")
XCTAssertEqual(store.paths, ["/media/42"])
XCTAssertEqual(store.entries["/media/42"]?.bytes, 10)
let again = EpisodeStore(directory: dir)
XCTAssertEqual(again.fileURL(for: "/media/42")?.lastPathComponent, "media-42.mp3")
}
func testAFileTheSystemTookBackIsNotKept() throws {
var store = EpisodeStore(directory: dir)
try store.add("/media/7/deadair", moving: tempFile(), ext: "m4a")
try FileManager.default.removeItem(at: store.fileURL(for: "/media/7/deadair")!)
XCTAssertNil(EpisodeStore(directory: dir).fileURL(for: "/media/7/deadair"))
}
func testRemovingTakesTheFileToo() throws {
var store = EpisodeStore(directory: dir)
try store.add("/media/1", moving: tempFile(), ext: "mp3")
let file = store.fileURL(for: "/media/1")!
store.remove("/media/1")
XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
XCTAssertTrue(store.paths.isEmpty)
}
func testTheTypeIsTakenFromTheServersAnswer() {
XCTAssertEqual(EpisodeStore.ext(forMime: "audio/mpeg"), "mp3")
XCTAssertEqual(EpisodeStore.ext(forMime: "audio/x-m4a"), "m4a")
XCTAssertEqual(EpisodeStore.ext(forMime: "audio/mp4; codecs=mp4a.40.2"), "m4a")
XCTAssertEqual(EpisodeStore.ext(forMime: nil), "mp3")
}
func testANewerPlaceReplacesTheOlderAndOtherWritesAreLeftOut() {
Outbox.add(path: "/api/entries/f/g/position", json: ["secs": 10])
Outbox.add(path: "/api/entries/f/g/flags", json: ["read": true])
Outbox.add(path: "/api/entries/f/g/position", json: ["secs": 90])
Outbox.add(path: "/api/feeds", json: ["url": "x"])
let all = Outbox.pending
XCTAssertEqual(all.map(\.path), ["/api/entries/f/g/flags", "/api/entries/f/g/position"])
XCTAssertEqual(String(data: all[1].json, encoding: .utf8), #"{"secs":90}"#)
}
}

View File

@@ -25,7 +25,7 @@ targets:
properties:
CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "5"
CFBundleVersion: "6"
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
UIBackgroundModes: [audio, fetch]