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

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