iOS: the player goes to ipxapi.sdf1.net with one API token; unread badge (#419, #421)

The Access service token plus API token pair was three values to enter
and Access in front of both. The player, the badge and CarPlay now send
the ipx API token alone to https://ipxapi.sdf1.net: a route of the
rays-unraid tunnel to the same ipx-web, with no Access application, held
to the paths the app uses by a custom firewall rule on the sdf1.net zone
(403 for anything without a bearer header or off the list: the page, the
login form, /api/login, ?token= sign-in, the admin API). docs/sso.md
says so, since it also says not to bypass Access for /api/*; this is
that warning's narrow version, and the rule is on Cloudflare, not here.

The unread badge (#421) shows All Subscriptions' count, as the page's
badge does: set when the app comes forward, after an item is read, and
by a background refresh iOS runs now and then. The test target had no
Info.plist and could never have run; it does now.

Build 4. Not yet run on a phone with the token set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 14:11:03 -05:00
parent 7d4456bb2f
commit 3626a30b52
14 changed files with 234 additions and 64 deletions

2
ios/.envrc Normal file
View File

@@ -0,0 +1,2 @@
export CF_ACCESS_CLIENT_ID=aaed67f65e3c3a2ce582cb9251c18b49.access
export CF_ACCESS_CLIENT_SECRET=cfast_xaYyFIccZElf8AnrKFZ9Bd0e8f3auXJh8fEOUJhY2947cfc9

View File

@@ -7,4 +7,5 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
### Added
- 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 tokens (shake, then Player Tokens): an ipx API token and a Cloudflare Access service token, kept in the keychain, which the player sends instead of the page's sign-in cookie, so playback does not stop when that cookie expires (#419).
- 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).

View File

@@ -29,15 +29,19 @@ Shake the phone to change it. Plain `http://` only on your own network
that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the
player uses (`CookieBridge`). `-ipx.server <url>` as a launch argument overrides it for a run.
## Player tokens
## Player token
The player normally rides on the web view's cookie, which ends with Cloudflare Access's session.
To make it outlast that, shake the phone, choose Player Tokens and enter three things, all kept
in the keychain (`Credentials.swift`): an ipx API token (the page's Settings, API tokens), and the
Client ID and Client Secret of an Access service token (Zero Trust, Access, Service credentials),
which the `ipodderx` application needs a *Service Auth* policy for. Without all three the player
sends only the cookie, as before. Playback's own requests use them (the audio, positions, the
artwork); the page still signs in as it did.
To make it outlast that, shake the phone, choose Player Token and enter an ipx API token (the
page's Settings, API tokens) and the address to send it to, `https://ipxapi.sdf1.net`. The token is
kept in the keychain (`Credentials.swift`). The player's audio, positions, read flags, artwork and
the unread badge go to that address with `Authorization: Bearer`; the page still signs in as it
did, at the server address. Without a token the player sends only the cookie, as before.
`ipxapi.sdf1.net` is the tunnel's route to the same ipx-web as `ipodderx.sdf1.net`, with no Access
application in front, which would turn a bearer header away. Cloudflare's firewall (a custom rule
on the `sdf1.net` zone) blocks every request to it except a bearer-token one for `/api/feeds`,
`/api/me`, `/api/art`, `/api/entries/*` and `/media/*`; ipx still checks the token.
## How it hangs together
@@ -83,7 +87,7 @@ its own clock, so both happen while the page is frozen. Video stays in the page.
* 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.
* Without the player tokens the cookie is the only credential: when Cloudflare Access's expires
* 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
`Playback`.

View File

@@ -1,7 +1,13 @@
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {}
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
Badge.registerBackgroundRefresh()
return true
}
}
/// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would
/// be a second scene beside this one.
@@ -16,4 +22,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
window.makeKeyAndVisible()
self.window = window
}
func sceneDidBecomeActive(_ scene: UIScene) {
Badge.requestPermission()
Badge.scheduleBackgroundRefresh()
Task { await Badge.refresh() }
}
}

75
ios/Sources/Badge.swift Normal file
View File

@@ -0,0 +1,75 @@
import BackgroundTasks
import UIKit
import UserNotifications
/// The number on the icon: All Subscriptions' unread count, as the page's badge shows it (#421).
///
/// Set when the app comes forward, after the player marks an item read, and by a background
/// refresh while it is closed. iOS decides when that runs (a few times a day for an app that is
/// opened often), so the number can be hours behind; push would make it exact and needs a sender
/// in ipx.
enum Badge {
static let taskId = "net.sdf1.ipx.refresh"
private struct Row: Decodable {
let id: String
let group: String?
let unread: Int?
let hide_from_all: Bool?
}
/// web/src/feeds.ts `unreadInAll`: a feed counts unless it, or the OPML it came from, is
/// hidden from All Subscriptions. Nil when the body is not a feed list, so an error page
/// does not turn the badge to zero.
static func unread(inAll body: Data) -> Int? {
guard let rows = try? JSONDecoder().decode([Row].self, from: body) else { return nil }
let hidden = Set(rows.filter { $0.hide_from_all == true }.map(\.id))
return rows.reduce(0) { n, f in
if f.hide_from_all == true || f.group.map(hidden.contains) == true { return n }
return n + max(0, f.unread ?? 0)
}
}
/// Asks once, for the badge alone: no alerts, no sounds.
static func requestPermission() {
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
}
/// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer
/// with a feed list: an old number is better than a wrong one.
@discardableResult
static func refresh() async -> Bool {
guard let url = Server.resolveForPlayer("/api/feeds") else { return false }
var req = URLRequest(url: url)
req.timeoutInterval = 20
req.setValue("application/json", forHTTPHeaderField: "Accept")
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
let cfg = URLSessionConfiguration.default
cfg.httpCookieStorage = .shared
guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req),
(resp as? HTTPURLResponse)?.statusCode == 200,
let n = unread(inAll: data) else { return false }
try? await UNUserNotificationCenter.current().setBadgeCount(n)
return true
}
// MARK: - while the app is closed
/// Before the app finishes launching, which is when iOS wants the handler registered.
static func registerBackgroundRefresh() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskId, using: nil) { task in
// The next one first, so a run that fails or is cut short does not end the chain.
scheduleBackgroundRefresh()
let work = Task {
task.setTaskCompleted(success: await refresh())
}
task.expirationHandler = { work.cancel() }
}
}
static func scheduleBackgroundRefresh() {
let req = BGAppRefreshTaskRequest(identifier: taskId)
req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
try? BGTaskScheduler.shared.submit(req)
}
}

View File

@@ -41,7 +41,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
case "load":
guard let path = m["url"] as? String, let url = Server.resolve(path),
guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path),
let enc = m["enc"] as? Int,
let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return }
playback.load(.init(

View File

@@ -4,24 +4,17 @@ import Security
/// What the player and CarPlay send instead of the web view's cookie (#419).
///
/// The cookie Cloudflare Access leaves in the web view lasts as long as its session, and when it
/// ends mid-drive the player has nothing. Two things that do not end by themselves replace it:
/// an ipx API token (Settings, API tokens), which ipx reads as `Authorization: Bearer`, and an
/// Access service token (Zero Trust, Service Auth), which Cloudflare reads as
/// `CF-Access-Client-Id` and `CF-Access-Client-Secret` before the request reaches ipx. Either
/// half alone is refused somewhere, so `headers` is empty unless it has all three, and the player
/// then falls back to the cookie as before.
/// ends mid-drive the player has nothing. An ipx API token (Settings, API tokens), which ipx reads
/// as `Authorization: Bearer`, does not end by itself. Access would turn a request carrying only
/// that away, so the player sends it to a second address with no Access in front, which
/// Cloudflare's firewall holds to the paths the player uses (`Server.apiURL`).
enum Credentials {
enum Field: String, CaseIterable {
case apiToken = "ipx.api-token"
case accessId = "ipx.access-client-id"
case accessSecret = "ipx.access-client-secret"
}
private static let service = "net.sdf1.ipx"
private static let account = "ipx.api-token"
static subscript(field: Field) -> String? {
static var apiToken: String? {
get {
var query = base(field)
var query = base
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var out: CFTypeRef?
@@ -30,10 +23,10 @@ enum Credentials {
return String(data: data, encoding: .utf8)
}
set {
SecItemDelete(base(field) as CFDictionary)
SecItemDelete(base as CFDictionary)
guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return }
var item = base(field)
var item = base
item[kSecValueData as String] = Data(value.utf8)
// After first unlock, not when unlocked: CarPlay and a locked phone both play.
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
@@ -41,19 +34,17 @@ enum Credentials {
}
}
private static func base(_ field: Field) -> [String: Any] {
private static var base: [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: field.rawValue]
kSecAttrAccount as String: account]
}
/// The headers for a request to the server, or none when any of the three is missing.
/// Empty unless there is a token, and an address to send it to: sent to the page's own
/// address it would only be turned away by Access.
static var headers: [String: String] {
guard let token = self[.apiToken], let id = self[.accessId],
let secret = self[.accessSecret] else { return [:] }
return ["Authorization": "Bearer \(token)",
"CF-Access-Client-Id": id,
"CF-Access-Client-Secret": secret]
guard let token = apiToken, Server.apiURL != nil else { return [:] }
return ["Authorization": "Bearer \(token)"]
}
static var isSet: Bool { !headers.isEmpty }

View File

@@ -95,7 +95,7 @@ final class Playback: NSObject {
// second has something to do about it.
let msg = pi.error?.localizedDescription ?? "the file could not be played"
let status = await self.request("GET", "/api/me")
let expired = Credentials.isSet ? "Check the tokens in Settings" : "Sign in again"
let expired = Credentials.isSet ? "Check the player token in Settings" : "Sign in again"
self.onError?(status == 401 || status == 403 ? expired : msg)
default: break
}
@@ -204,7 +204,10 @@ final class Playback: NSObject {
private func markRead() {
guard !markedRead, let item else { return }
markedRead = true
Task { await request("POST", entryPath(item, "flags"), ["read": true]) }
Task {
await request("POST", entryPath(item, "flags"), ["read": true])
await Badge.refresh()
}
}
// MARK: - the lock screen and the car
@@ -256,7 +259,7 @@ final class Playback: NSObject {
let centre = MPNowPlayingInfoCenter.default()
centre.nowPlayingInfo = info
guard let art = item.artwork, let url = Server.resolve(art) else { return }
guard let art = item.artwork, let url = Server.resolveForPlayer(art) else { return }
Task {
var artReq = URLRequest(url: url)
Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) }
@@ -295,7 +298,7 @@ final class Playback: NSObject {
/// arrived. A position that does not save is saved again ten seconds on.
@discardableResult
private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? {
guard let url = Server.resolve(path) else { return nil }
guard let url = Server.resolveForPlayer(path) else { return nil }
var req = URLRequest(url: url)
req.httpMethod = method
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }

View File

@@ -40,4 +40,22 @@ enum Server {
guard let base = url else { return nil }
return URL(string: path, relativeTo: base)?.absoluteURL
}
/// Where the player, the badge and CarPlay talk to ipx, which is not where the page is: an
/// address with no Cloudflare Access in front, for the API token alone (#419). Always https,
/// since the token travels in a header.
private static let apiKey = "ipx.api"
static let suggestedAPI = "https://ipxapi.sdf1.net"
static var apiURL: URL? {
get { UserDefaults.standard.string(forKey: apiKey).flatMap(normalise).flatMap { $0.scheme == "https" ? $0 : nil } }
set { UserDefaults.standard.set(newValue?.absoluteString, forKey: apiKey) }
}
/// A path against the player's address when there are credentials to send there, else the
/// page's, where the web view's cookie does the signing in.
static func resolveForPlayer(_ path: String) -> URL? {
guard Credentials.isSet, let base = apiURL else { return resolve(path) }
return URL(string: path, relativeTo: base)?.absoluteURL
}
}

View File

@@ -63,7 +63,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
f.clearButtonMode = .whileEditing
}
if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) }
alert.addAction(UIAlertAction(title: "Player Tokens…", style: .default) { [weak self] _ in
alert.addAction(UIAlertAction(title: "Player Token…", style: .default) { [weak self] _ in
self?.askForCredentials()
})
alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in
@@ -81,36 +81,41 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
present(alert, animated: true)
}
/// The tokens the player and CarPlay use in place of the page's cookie (#419). Left as they
/// are when a field is empty; all three emptied clears them.
private func askForCredentials() {
/// The token the player and CarPlay use in place of the page's cookie, and the address they
/// send it to (#419). An empty token field keeps the one there is; Clear removes it.
private func askForCredentials(_ problem: String? = nil) {
guard presentedViewController == nil else { return }
let alert = UIAlertController(
title: "Player tokens",
message: "An ipx API token (Settings, API tokens) and a Cloudflare Access service token, "
+ "so playback and CarPlay keep working when the page's sign-in ends. "
+ (Credentials.isSet ? "Set now; leave a field empty to keep it." : "Not set."),
title: "Player token",
message: problem ?? "An ipx API token (Settings, API tokens) so playback and CarPlay keep "
+ "working when the page's sign-in ends. "
+ (Credentials.isSet ? "Set now; leave the token empty to keep it." : "Not set."),
preferredStyle: .alert)
let fields: [(String, Credentials.Field)] = [
("ipx API token", .apiToken), ("Access client ID", .accessId),
("Access client secret", .accessSecret),
]
for (placeholder, _) in fields {
alert.addTextField { f in
f.placeholder = placeholder
f.isSecureTextEntry = true
f.autocapitalizationType = .none
f.autocorrectionType = .no
}
alert.addTextField { f in
f.placeholder = "ipx API token"
f.isSecureTextEntry = true
f.autocapitalizationType = .none
f.autocorrectionType = .no
}
alert.addTextField { f in
f.text = Server.apiURL?.absoluteString ?? Server.suggestedAPI
f.keyboardType = .URL
f.autocapitalizationType = .none
f.autocorrectionType = .no
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in
Credentials.Field.allCases.forEach { Credentials[$0] = nil }
Credentials.apiToken = nil
})
alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak alert] _ in
for (i, (_, field)) in fields.enumerated() {
if let v = alert?.textFields?[i].text, !v.isEmpty { Credentials[field] = v }
alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in
let fields = alert?.textFields ?? []
guard fields.count == 2, let url = Server.normalise(fields[1].text ?? ""),
url.scheme == "https" else {
self?.askForCredentials("The player address has to be https, such as \(Server.suggestedAPI).")
return
}
Server.apiURL = url
if let token = fields[0].text, !token.isEmpty { Credentials.apiToken = token }
})
present(alert, animated: true)
}

View File

@@ -0,0 +1,24 @@
import XCTest
@testable import iPX
final class BadgeTests: XCTestCase {
private func count(_ json: String) -> Int? { Badge.unread(inAll: Data(json.utf8)) }
func testItCountsTheUnreadOfFeedsInAll() {
XCTAssertEqual(count(#"[{"id":"a","unread":3},{"id":"b","unread":4,"hide_from_all":false}]"#), 7)
}
func testAFeedHiddenFromAllIsLeftOut() {
XCTAssertEqual(count(#"[{"id":"a","unread":3},{"id":"b","unread":4,"hide_from_all":true}]"#), 3)
}
func testAFeedFromAnOPMLHiddenFromAllIsLeftOut() {
let json = #"[{"id":"o","unread":0,"hide_from_all":true},{"id":"a","unread":5,"group":"o"},{"id":"b","unread":2}]"#
XCTAssertEqual(count(json), 2)
}
func testAnErrorBodyIsNotAFeedListAndIsNotZero() {
XCTAssertNil(count(#"{"error":"unauthorised"}"#))
XCTAssertNil(count("<html>sign in</html>"))
}
}

View File

@@ -21,4 +21,22 @@ final class ServerTests: XCTestCase {
XCTAssertEqual(Server.resolve("/media/42/deadair.m3u8?traceparent=x")?.absoluteString,
"https://ipodderx.sdf1.net/media/42/deadair.m3u8?traceparent=x")
}
func testThePlayerGoesToItsOwnAddressOnlyWithAToken() {
UserDefaults.standard.set("https://ipodderx.sdf1.net", forKey: "ipx.server")
UserDefaults.standard.set("https://ipxapi.sdf1.net", forKey: "ipx.api")
Credentials.apiToken = nil
XCTAssertEqual(Server.resolveForPlayer("/media/1")?.host, "ipodderx.sdf1.net")
Credentials.apiToken = "t"
XCTAssertEqual(Server.resolveForPlayer("/media/1")?.host, "ipxapi.sdf1.net")
XCTAssertEqual(Credentials.headers["Authorization"], "Bearer t")
Credentials.apiToken = nil
UserDefaults.standard.removeObject(forKey: "ipx.api")
}
func testThePlayerAddressIsNeverPlainHTTP() {
UserDefaults.standard.set("http://192.168.1.130:8099", forKey: "ipx.api")
XCTAssertNil(Server.apiURL)
UserDefaults.standard.removeObject(forKey: "ipx.api")
}
}

View File

@@ -25,10 +25,12 @@ targets:
properties:
CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "2"
CFBundleVersion: "4"
# 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]
UIBackgroundModes: [audio, fetch]
# The unread badge's refresh while the app is closed (#421).
BGTaskSchedulerPermittedIdentifiers: [net.sdf1.ipx.refresh]
# HTTPS only, so TestFlight need not ask the export-compliance question on every build.
ITSAppUsesNonExemptEncryption: false
UILaunchScreen: {}
@@ -63,6 +65,7 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx.tests
GENERATE_INFOPLIST_FILE: YES
schemes:
iPX: