Background refresh runs when iOS chooses, and for this app that has been rarely, so the icon's unread count lagged until the app was opened (#421). Now ipx sends the count: - ipx: a push_devices table and POST/DELETE /api/push/devices. With [push] set to an APNs key, a task compares each phone's All Subscriptions unread count with the last sent, once a minute, and sends a changed one to Apple as a badge-only push: no alert, no sound. A token Apple says is gone is forgotten. The signing token is ES256 over the team and time, made again every forty minutes, inside Apple's limits. - iOS: the aps-environment entitlement. The app registers for push once badge permission is given, each launch, and hands its token to the page. - Page: registers the token with ipx as the person signed in, since the player's address (ipxapi) lets through only the player's paths, and removes it on signing out. jsonwebtoken here is built without PEM support, so the .p8 key's base64 is decoded in push.rs rather than turning on a feature and changing Cargo.lock for one file. cargo test -p ipx on macOS: 182 pass; the 3 that fail read /proc and fail there without this change too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.4 KiB
Swift
100 lines
4.4 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() {
|
|
// `-ipx.noBadgePrompt YES` on the launch arguments: a simulator run that drives the page
|
|
// has nobody to tap Allow, and the dialog covers it.
|
|
if UserDefaults.standard.bool(forKey: "ipx.noBadgePrompt") { return }
|
|
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { granted, _ in
|
|
// Each launch, not once: Apple can change the token, and says to ask every time.
|
|
guard granted else { return }
|
|
Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() }
|
|
}
|
|
}
|
|
|
|
/// The page's own count, sent as its list changes (#421).
|
|
@MainActor static func set(_ n: Int) {
|
|
lastSet = Date()
|
|
Task { try? await UNUserNotificationCenter.current().setBadgeCount(max(0, n)) }
|
|
}
|
|
|
|
/// When the icon was last set from a count, by either way.
|
|
@MainActor private static var lastSet = Date.distantPast
|
|
|
|
/// While an episode plays with the screen locked the app is awake, unlike the page: so new
|
|
/// items reach the icon every quarter of an hour, which background refresh does not promise.
|
|
@MainActor static func refreshIfStale() {
|
|
guard Date().timeIntervalSince(lastSet) > 15 * 60 else { return }
|
|
lastSet = Date()
|
|
Task { await refresh() }
|
|
}
|
|
|
|
/// 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 }
|
|
await MainActor.run { set(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)
|
|
}
|
|
}
|