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:
@@ -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
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 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(
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user