admin.html's viewport meta lacked viewport-fit=cover, which index.html has. Without it env(safe-area-inset-top) is 0, so the top bar's padding did not clear the clock in the app's full-screen web view, and the back arrow sat under it and could not be tapped. Seen in the simulator, the built page served locally: the arrow and the title level with the clock before, below the status bar after. Also a launch argument, -ipx.noBadgePrompt, so a simulator run can get past the badge permission dialog. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
79 lines
3.4 KiB
Swift
79 lines
3.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]) { _, _ 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)
|
|
}
|
|
}
|