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:
14
docs/sso.md
14
docs/sso.md
@@ -261,5 +261,19 @@ the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client
|
|||||||
API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the
|
API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the
|
||||||
only thing between the internet and the API.
|
only thing between the internet and the API.
|
||||||
|
|
||||||
|
### `ipxapi.sdf1.net`, for the iOS app (#419)
|
||||||
|
|
||||||
|
The app's player cannot send an Access service token's pair of headers as well as sign in, and its
|
||||||
|
cookie from Access ends mid-drive, so it has an address of its own: `ipxapi.sdf1.net`, a route of
|
||||||
|
the `rays-unraid` tunnel to the same `192.168.1.130:8099` as `ipodderx.sdf1.net`, with a proxied
|
||||||
|
CNAME to the tunnel and **no Access application**. That is the exposure the paragraph above warns
|
||||||
|
against, held narrow another way: a custom firewall rule on the `sdf1.net` zone (Security, WAF)
|
||||||
|
blocks every request to that host unless it carries `Authorization: Bearer` and is for
|
||||||
|
`/api/feeds`, `/api/me`, `/api/art`, `/api/entries/*` or `/media/*`. So the page, the sign-in form,
|
||||||
|
`/api/login`, `?token=` sign-in and the admin API answer 403 there, and ipx still checks the
|
||||||
|
token on what is let through. A new path the app needs is added to that rule's expression. The
|
||||||
|
rule is on Cloudflare, not in this repository; if the hostname is ever served without it, the
|
||||||
|
whole of ipx is on the internet behind bearer tokens alone.
|
||||||
|
|
||||||
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
|
See also [users.md](users.md) for what several people share, [configuration.md](configuration.md)
|
||||||
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.
|
for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands.
|
||||||
|
|||||||
2
ios/.envrc
Normal file
2
ios/.envrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export CF_ACCESS_CLIENT_ID=aaed67f65e3c3a2ce582cb9251c18b49.access
|
||||||
|
export CF_ACCESS_CLIENT_SECRET=cfast_xaYyFIccZElf8AnrKFZ9Bd0e8f3auXJh8fEOUJhY2947cfc9
|
||||||
@@ -7,4 +7,5 @@ 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).
|
- 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).
|
||||||
|
|||||||
@@ -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
|
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
|
## Player token
|
||||||
|
|
||||||
The player normally rides on the web view's cookie, which ends with Cloudflare Access's session.
|
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
|
To make it outlast that, shake the phone, choose Player Token and enter an ipx API token (the
|
||||||
in the keychain (`Credentials.swift`): an ipx API token (the page's Settings, API tokens), and the
|
page's Settings, API tokens) and the address to send it to, `https://ipxapi.sdf1.net`. The token is
|
||||||
Client ID and Client Secret of an Access service token (Zero Trust, Access, Service credentials),
|
kept in the keychain (`Credentials.swift`). The player's audio, positions, read flags, artwork and
|
||||||
which the `ipodderx` application needs a *Service Auth* policy for. Without all three the player
|
the unread badge go to that address with `Authorization: Bearer`; the page still signs in as it
|
||||||
sends only the cookie, as before. Playback's own requests use them (the audio, positions, the
|
did, at the server address. Without a token the player sends only the cookie, as before.
|
||||||
artwork); the page still signs in as it did.
|
|
||||||
|
`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
|
## 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
|
* 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.
|
||||||
* 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.
|
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`.
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@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
|
/// 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.
|
/// be a second scene beside this one.
|
||||||
@@ -16,4 +22,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||||||
window.makeKeyAndVisible()
|
window.makeKeyAndVisible()
|
||||||
self.window = window
|
self.window = window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sceneDidBecomeActive(_ scene: UIScene) {
|
||||||
|
Badge.requestPermission()
|
||||||
|
Badge.scheduleBackgroundRefresh()
|
||||||
|
Task { await Badge.refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
ios/Sources/Badge.swift
Normal file
75
ios/Sources/Badge.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ final class Bridge: NSObject, WKScriptMessageHandler {
|
|||||||
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
if let r = m["rate"] as? Double { playback.setRate(Float(r)) }
|
||||||
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
if let v = m["volume"] as? Double { playback.setVolume(Float(v)) }
|
||||||
case "load":
|
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 enc = m["enc"] as? Int,
|
||||||
let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return }
|
let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return }
|
||||||
playback.load(.init(
|
playback.load(.init(
|
||||||
|
|||||||
@@ -4,24 +4,17 @@ import Security
|
|||||||
/// What the player and CarPlay send instead of the web view's cookie (#419).
|
/// 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
|
/// 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:
|
/// ends mid-drive the player has nothing. An ipx API token (Settings, API tokens), which ipx reads
|
||||||
/// an ipx API token (Settings, API tokens), which ipx reads as `Authorization: Bearer`, and an
|
/// as `Authorization: Bearer`, does not end by itself. Access would turn a request carrying only
|
||||||
/// Access service token (Zero Trust, Service Auth), which Cloudflare reads as
|
/// that away, so the player sends it to a second address with no Access in front, which
|
||||||
/// `CF-Access-Client-Id` and `CF-Access-Client-Secret` before the request reaches ipx. Either
|
/// Cloudflare's firewall holds to the paths the player uses (`Server.apiURL`).
|
||||||
/// 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 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 service = "net.sdf1.ipx"
|
||||||
|
private static let account = "ipx.api-token"
|
||||||
|
|
||||||
static subscript(field: Field) -> String? {
|
static var apiToken: String? {
|
||||||
get {
|
get {
|
||||||
var query = base(field)
|
var query = base
|
||||||
query[kSecReturnData as String] = true
|
query[kSecReturnData as String] = true
|
||||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
var out: CFTypeRef?
|
var out: CFTypeRef?
|
||||||
@@ -30,10 +23,10 @@ enum Credentials {
|
|||||||
return String(data: data, encoding: .utf8)
|
return String(data: data, encoding: .utf8)
|
||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
SecItemDelete(base(field) as CFDictionary)
|
SecItemDelete(base as CFDictionary)
|
||||||
guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!value.isEmpty else { return }
|
!value.isEmpty else { return }
|
||||||
var item = base(field)
|
var item = base
|
||||||
item[kSecValueData as String] = Data(value.utf8)
|
item[kSecValueData as String] = Data(value.utf8)
|
||||||
// After first unlock, not when unlocked: CarPlay and a locked phone both play.
|
// After first unlock, not when unlocked: CarPlay and a locked phone both play.
|
||||||
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
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,
|
[kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
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] {
|
static var headers: [String: String] {
|
||||||
guard let token = self[.apiToken], let id = self[.accessId],
|
guard let token = apiToken, Server.apiURL != nil else { return [:] }
|
||||||
let secret = self[.accessSecret] else { return [:] }
|
return ["Authorization": "Bearer \(token)"]
|
||||||
return ["Authorization": "Bearer \(token)",
|
|
||||||
"CF-Access-Client-Id": id,
|
|
||||||
"CF-Access-Client-Secret": secret]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static var isSet: Bool { !headers.isEmpty }
|
static var isSet: Bool { !headers.isEmpty }
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ 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")
|
||||||
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)
|
self.onError?(status == 401 || status == 403 ? expired : msg)
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,10 @@ final class Playback: NSObject {
|
|||||||
private func markRead() {
|
private func markRead() {
|
||||||
guard !markedRead, let item else { return }
|
guard !markedRead, let item else { return }
|
||||||
markedRead = true
|
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
|
// MARK: - the lock screen and the car
|
||||||
@@ -256,7 +259,7 @@ final class Playback: NSObject {
|
|||||||
let centre = MPNowPlayingInfoCenter.default()
|
let centre = MPNowPlayingInfoCenter.default()
|
||||||
centre.nowPlayingInfo = info
|
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 {
|
Task {
|
||||||
var artReq = URLRequest(url: url)
|
var artReq = URLRequest(url: url)
|
||||||
Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) }
|
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.
|
/// 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.resolveForPlayer(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) }
|
Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
|
||||||
|
|||||||
@@ -40,4 +40,22 @@ enum Server {
|
|||||||
guard let base = url else { return nil }
|
guard let base = url else { return nil }
|
||||||
return URL(string: path, relativeTo: base)?.absoluteURL
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ 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
|
alert.addAction(UIAlertAction(title: "Player Token…", style: .default) { [weak self] _ in
|
||||||
self?.askForCredentials()
|
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
|
||||||
@@ -81,36 +81,41 @@ 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
|
/// The token the player and CarPlay use in place of the page's cookie, and the address they
|
||||||
/// are when a field is empty; all three emptied clears them.
|
/// send it to (#419). An empty token field keeps the one there is; Clear removes it.
|
||||||
private func askForCredentials() {
|
private func askForCredentials(_ problem: String? = nil) {
|
||||||
guard presentedViewController == nil else { return }
|
guard presentedViewController == nil else { return }
|
||||||
let alert = UIAlertController(
|
let alert = UIAlertController(
|
||||||
title: "Player tokens",
|
title: "Player token",
|
||||||
message: "An ipx API token (Settings, API tokens) and a Cloudflare Access service token, "
|
message: problem ?? "An ipx API token (Settings, API tokens) so playback and CarPlay keep "
|
||||||
+ "so playback and CarPlay keep working when the page's sign-in ends. "
|
+ "working when the page's sign-in ends. "
|
||||||
+ (Credentials.isSet ? "Set now; leave a field empty to keep it." : "Not set."),
|
+ (Credentials.isSet ? "Set now; leave the token empty to keep it." : "Not set."),
|
||||||
preferredStyle: .alert)
|
preferredStyle: .alert)
|
||||||
let fields: [(String, Credentials.Field)] = [
|
alert.addTextField { f in
|
||||||
("ipx API token", .apiToken), ("Access client ID", .accessId),
|
f.placeholder = "ipx API token"
|
||||||
("Access client secret", .accessSecret),
|
f.isSecureTextEntry = true
|
||||||
]
|
f.autocapitalizationType = .none
|
||||||
for (placeholder, _) in fields {
|
f.autocorrectionType = .no
|
||||||
alert.addTextField { f in
|
}
|
||||||
f.placeholder = placeholder
|
alert.addTextField { f in
|
||||||
f.isSecureTextEntry = true
|
f.text = Server.apiURL?.absoluteString ?? Server.suggestedAPI
|
||||||
f.autocapitalizationType = .none
|
f.keyboardType = .URL
|
||||||
f.autocorrectionType = .no
|
f.autocapitalizationType = .none
|
||||||
}
|
f.autocorrectionType = .no
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
||||||
alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in
|
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
|
alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in
|
||||||
for (i, (_, field)) in fields.enumerated() {
|
let fields = alert?.textFields ?? []
|
||||||
if let v = alert?.textFields?[i].text, !v.isEmpty { Credentials[field] = v }
|
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)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|||||||
24
ios/Tests/BadgeTests.swift
Normal file
24
ios/Tests/BadgeTests.swift
Normal 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>"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,4 +21,22 @@ final class ServerTests: XCTestCase {
|
|||||||
XCTAssertEqual(Server.resolve("/media/42/deadair.m3u8?traceparent=x")?.absoluteString,
|
XCTAssertEqual(Server.resolve("/media/42/deadair.m3u8?traceparent=x")?.absoluteString,
|
||||||
"https://ipodderx.sdf1.net/media/42/deadair.m3u8?traceparent=x")
|
"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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: iPX
|
CFBundleDisplayName: iPX
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "2"
|
CFBundleVersion: "4"
|
||||||
# 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, 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.
|
# HTTPS only, so TestFlight need not ask the export-compliance question on every build.
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
@@ -63,6 +65,7 @@ targets:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx.tests
|
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx.tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
iPX:
|
iPX:
|
||||||
|
|||||||
Reference in New Issue
Block a user