iOS: player tokens in the keychain, and a build that uploads (#419)

The player rode only on the web view's Cloudflare Access cookie, so it
stopped with "Sign in again" when that session ended, and CarPlay (#420),
which has no web view, would have had nothing at all. Credentials.swift
keeps an ipx API token and an Access service token in the keychain, and
the player sends them (Authorization: Bearer, CF-Access-Client-Id and
-Secret) on its audio, position, read-flag and artwork requests. Without
all three it uses the cookie as before. Shake, Player Tokens enters them.

project.yml gets what TestFlight needs: the team, a numeric version
(App Store Connect refuses 0.1.0-dev), upside-down portrait (an iPad
build fails validation without every orientation) and
ITSAppUsesNonExemptEncryption. Resources/Info.plist is generated by
XcodeGen and now ignored.

Not run on a phone: the header option on AVURLAsset is read by name, as
the SDK has no constant for it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 13:08:15 -05:00
parent a581e3fc88
commit f6042e9b5f
7 changed files with 129 additions and 9 deletions

1
ios/.gitignore vendored
View File

@@ -1,3 +1,4 @@
*.xcodeproj/ *.xcodeproj/
build/ build/
DerivedData/ DerivedData/
Resources/Info.plist

View File

@@ -7,3 +7,4 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
### Added ### 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). - 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).

View File

@@ -29,6 +29,16 @@ 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 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 uses (`CookieBridge`). `-ipx.server <url>` as a launch argument overrides it for a run.
## Player tokens
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.
## How it hangs together ## How it hangs together
| | | | | |
@@ -73,7 +83,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 * 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. not run without App-Bound Domains; the app plays them from the server.
* The cookie is the only credential: when Cloudflare Access's expires mid-drive, playback stops * Without the player tokens the cookie is the only credential: when Cloudflare Access's expires
with "Sign in again", and opening the app signs in. A token in the keychain would end that. 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 * CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same
`Playback`. `Playback`.

View File

@@ -0,0 +1,60 @@
import Foundation
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.
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"
static subscript(field: Field) -> String? {
get {
var query = base(field)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var out: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
let data = out as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
set {
SecItemDelete(base(field) as CFDictionary)
guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return }
var item = base(field)
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
SecItemAdd(item as CFDictionary, nil)
}
}
private static func base(_ field: Field) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: field.rawValue]
}
/// The headers for a request to the server, or none when any of the three is missing.
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]
}
static var isSet: Bool { !headers.isEmpty }
}

View File

@@ -69,10 +69,13 @@ final class Playback: NSObject {
lastSaved = 0 lastSaved = 0
// An AVURLAsset does not read the shared cookie store by itself, and the cookie is how // An AVURLAsset does not read the shared cookie store by itself, and the cookie is how
// ipx knows whose file this is. // ipx knows whose file this is, unless the credentials are set, which do not expire.
let asset = AVURLAsset(url: item.url, options: [ // The header option has no constant in the SDK; AVFoundation reads it by this name.
var options: [String: Any] = [
AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [], AVURLAssetHTTPCookiesKey: HTTPCookieStorage.shared.cookies(for: item.url) ?? [],
]) ]
if Credentials.isSet { options["AVURLAssetHTTPHeaderFieldsKey"] = Credentials.headers }
let asset = AVURLAsset(url: item.url, options: options)
let playerItem = AVPlayerItem(asset: asset) let playerItem = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: playerItem) let player = AVPlayer(playerItem: playerItem)
player.volume = volume player.volume = volume
@@ -92,7 +95,8 @@ final class Playback: NSObject {
// second has something to do about it. // second has something to do about it.
let msg = pi.error?.localizedDescription ?? "the file could not be played" let msg = pi.error?.localizedDescription ?? "the file could not be played"
let status = await self.request("GET", "/api/me") let status = await self.request("GET", "/api/me")
self.onError?(status == 401 ? "Sign in again" : msg) let expired = Credentials.isSet ? "Check the tokens in Settings" : "Sign in again"
self.onError?(status == 401 || status == 403 ? expired : msg)
default: break default: break
} }
} }
@@ -254,7 +258,9 @@ final class Playback: NSObject {
guard let art = item.artwork, let url = Server.resolve(art) else { return } guard let art = item.artwork, let url = Server.resolve(art) else { return }
Task { Task {
guard let (data, _) = try? await session.data(from: url), var artReq = URLRequest(url: url)
Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) }
guard let (data, _) = try? await session.data(for: artReq),
let image = UIImage(data: data), self.item?.guid == item.guid else { return } let image = UIImage(data: data), self.item?.guid == item.guid else { return }
// Read back, not `info`: by the time the picture arrives that elapsed time is stale, // Read back, not `info`: by the time the picture arrives that elapsed time is stale,
// and writing it would jerk the progress bar back. // and writing it would jerk the progress bar back.
@@ -285,13 +291,14 @@ final class Playback: NSObject {
return "/api/entries/\(esc(item.feedId))/\(esc(item.guid))/\(what)" return "/api/entries/\(esc(item.feedId))/\(esc(item.guid))/\(what)"
} }
/// One call to ipx, signed in by the web view's cookies; its status, or nil if it never /// One call to ipx, signed in by the credentials or else the web view's cookies; its status, or nil if it never
/// arrived. A position that does not save is saved again ten seconds on. /// arrived. A position that does not save is saved again ten seconds on.
@discardableResult @discardableResult
private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? { 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.resolve(path) else { return nil }
var req = URLRequest(url: url) var req = URLRequest(url: url)
req.httpMethod = method req.httpMethod = method
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
if let json { if let json {
req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: json) req.httpBody = try? JSONSerialization.data(withJSONObject: json)

View File

@@ -63,6 +63,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
f.clearButtonMode = .whileEditing f.clearButtonMode = .whileEditing
} }
if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) } if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) }
alert.addAction(UIAlertAction(title: "Player Tokens…", style: .default) { [weak self] _ in
self?.askForCredentials()
})
alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in
guard let self else { return } guard let self else { return }
let text = alert?.textFields?.first?.text ?? "" let text = alert?.textFields?.first?.text ?? ""
@@ -78,6 +81,40 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
present(alert, animated: true) 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() {
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."),
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.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in
Credentials.Field.allCases.forEach { Credentials[$0] = 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 }
}
})
present(alert, animated: true)
}
// MARK: - the page // MARK: - the page
/// Sign-in leaves the server's address (Cloudflare Access, its identity provider) and must /// Sign-in leaves the server's address (Cloudflare Access, its identity provider) and must

View File

@@ -29,6 +29,8 @@ targets:
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on # 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. # a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
UIBackgroundModes: [audio] UIBackgroundModes: [audio]
# HTTPS only, so TestFlight need not ask the export-compliance question on every build.
ITSAppUsesNonExemptEncryption: false
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false UIApplicationSupportsMultipleScenes: false
@@ -38,6 +40,7 @@ targets:
UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate UISceneDelegateClassName: $(PRODUCT_MODULE_NAME).SceneDelegate
UISupportedInterfaceOrientations: UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait - UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationLandscapeRight
# Plain HTTP only for a server on your own network (192.168.1.130:8099); the tunnel is HTTPS. # Plain HTTP only for a server on your own network (192.168.1.130:8099); the tunnel is HTTPS.
@@ -46,7 +49,8 @@ targets:
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
MARKETING_VERSION: "0.1.0-dev" MARKETING_VERSION: "0.1.0"
DEVELOPMENT_TEAM: 8743L8D729
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
iPXTests: iPXTests: