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>
61 lines
2.6 KiB
Swift
61 lines
2.6 KiB
Swift
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 }
|
|
}
|