Files
ipx/ios/Sources/Badge.swift
Ray Slakinski 3626a30b52 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>
2026-11-08 14:11:03 -05:00

76 lines
3.2 KiB
Swift

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